diff --git a/worlds/lufia2ac/Items.py b/worlds/lufia2ac/Items.py index 20159f48..190b913c 100644 --- a/worlds/lufia2ac/Items.py +++ b/worlds/lufia2ac/Items.py @@ -2,9 +2,8 @@ from enum import auto, Enum from typing import Dict, NamedTuple, Optional from BaseClasses import Item, ItemClassification -from . import Locations -start_id: int = Locations.start_id +start_id: int = 0xAC0000 class ItemType(Enum): @@ -500,7 +499,7 @@ l2ac_item_table: Dict[str, ItemData] = { # 0x01C8: "Key28" # 0x01C9: "Key29" # 0x01CA: "AP item" # replaces "Key30" - # 0x01CB: "Crown" + # 0x01CB: "SOLD OUT" # replaces "Crown" # 0x01CC: "Ruby apple" # 0x01CD: "PURIFIA" # 0x01CE: "Tag ring" diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py index 3f1c58f9..783da8e4 100644 --- a/worlds/lufia2ac/Options.py +++ b/worlds/lufia2ac/Options.py @@ -1,13 +1,16 @@ from __future__ import annotations +import functools +import numbers import random from dataclasses import dataclass from itertools import accumulate, chain, combinations from typing import Any, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Type, TYPE_CHECKING, Union -from Options import AssembleOptions, Choice, DeathLink, ItemDict, PerGameCommonOptions, Range, SpecialRange, \ - TextChoice, Toggle +from Options import AssembleOptions, Choice, DeathLink, ItemDict, OptionDict, PerGameCommonOptions, Range, \ + SpecialRange, TextChoice, Toggle from .Enemies import enemy_name_to_sprite +from .Items import ItemType, l2ac_item_table if TYPE_CHECKING: from BaseClasses import PlandoOptions @@ -558,6 +561,25 @@ class Goal(Choice): default = option_boss +class GoldModifier(Range): + """Percentage modifier for gold gained from enemies. + + Supported values: 25 – 400 + Default value: 100 (same as in an unmodified game) + """ + + display_name = "Gold modifier" + range_start = 25 + range_end = 400 + default = 100 + + def __call__(self, gold: bytes) -> bytes: + try: + return (int.from_bytes(gold, "little") * self.value // 100).to_bytes(2, "little") + except OverflowError: + return b"\xFF\xFF" + + class HealingFloorChance(Range): """The chance of a floor having a healing tile hidden under a bush. @@ -662,6 +684,105 @@ class RunSpeed(Choice): default = option_disabled +class ShopInterval(SpecialRange): + """Place shops after a certain number of floors. + + E.g., if you set this to 5, then you will be given the opportunity to shop after completing B5, B10, B15, etc., + whereas if you set it to 1, then there will be a shop after every single completed floor. + Shops will offer a random selection of wares; on deeper floors, more expensive items might appear. + You can customize the stock that can appear in shops using the shop_inventory option. + You can control how much gold you will be obtaining from enemies using the gold_multiplier option. + Supported values: disabled, 1 – 10 + Default value: disabled (same as in an unmodified game) + """ + + display_name = "Shop interval" + range_start = 0 + range_end = 10 + default = 0 + special_range_cutoff = 1 + special_range_names = { + "disabled": 0, + } + + +class ShopInventory(OptionDict): + """Determine the item types that can appear in shops. + + The value of this option should be a mapping of item categories (or individual items) to weights (non-negative + integers), which are used as relative probabilities when it comes to including these things in shops. (The actual + contents of the generated shops are selected randomly and are subject to additional constraints such as more + expensive things being allowed only on later floors.) + Supported keys: + non_restorative — a selection of mostly non-restorative red chest consumables + restorative — all HP- or MP-restoring red chest consumables + blue_chest — all blue chest items + spell — all red chest spells + gear — all red chest armors, shields, headgear, rings, and rocks (this respects the gear_variety_after_b9 option, + meaning that you will not encounter any shields, headgear, rings, or rocks in shops from B10 onward unless you + also enabled that option) + weapon — all red chest weapons + Additionally, you can also add extra weights for any specific cave item. If you want your shops to have a + higher than normal chance of selling a Dekar blade, you can, e.g., add "Dekar blade: 5". + You can even forego the predefined categories entirely and design a custom shop pool from scratch by providing + separate weights for each item you want to include. + (Spells, however, cannot be weighted individually and are only available as part of the "spell" category.) + Default value: {spell: 30, gear: 45, weapon: 82} + """ + + display_name = "Shop inventory" + _special_keys = {"non_restorative", "restorative", "blue_chest", "spell", "gear", "weapon"} + valid_keys = _special_keys | {item for item, data in l2ac_item_table.items() + if data.type in {ItemType.BLUE_CHEST, ItemType.ENEMY_DROP, ItemType.ENTRANCE_CHEST, + ItemType.RED_CHEST, ItemType.RED_CHEST_PATCH}} + default: Dict[str, int] = { + "spell": 30, + "gear": 45, + "weapon": 82, + } + value: Dict[str, int] + + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: + super().verify(world, player_name, plando_options) + for item, weight in self.value.items(): + if not isinstance(weight, numbers.Integral) or weight < 0: + raise Exception(f"Weight for item \"{item}\" from option {self} must be a non-negative integer, " + f"but was \"{weight}\".") + + @property + def total(self) -> int: + return sum(self.value.values()) + + @property + def non_restorative(self) -> int: + return self.value.get("non_restorative", 0) + + @property + def restorative(self) -> int: + return self.value.get("restorative", 0) + + @property + def blue_chest(self) -> int: + return self.value.get("blue_chest", 0) + + @property + def spell(self) -> int: + return self.value.get("spell", 0) + + @property + def gear(self) -> int: + return self.value.get("gear", 0) + + @property + def weapon(self) -> int: + return self.value.get("weapon", 0) + + @functools.cached_property + def custom(self) -> Dict[int, int]: + return {l2ac_item_table[item].code & 0x01FF: weight for item, weight in self.value.items() + if item not in self._special_keys} + + class ShuffleCapsuleMonsters(Toggle): """Shuffle the capsule monsters into the multiworld. @@ -717,6 +838,7 @@ class L2ACOptions(PerGameCommonOptions): final_floor: FinalFloor gear_variety_after_b9: GearVarietyAfterB9 goal: Goal + gold_modifier: GoldModifier healing_floor_chance: HealingFloorChance initial_floor: InitialFloor iris_floor_chance: IrisFloorChance @@ -724,5 +846,7 @@ class L2ACOptions(PerGameCommonOptions): master_hp: MasterHp party_starting_level: PartyStartingLevel run_speed: RunSpeed + shop_interval: ShopInterval + shop_inventory: ShopInventory shuffle_capsule_monsters: ShuffleCapsuleMonsters shuffle_party_members: ShufflePartyMembers diff --git a/worlds/lufia2ac/Utils.py b/worlds/lufia2ac/Utils.py index 6c2e28d1..1fd7e0e1 100644 --- a/worlds/lufia2ac/Utils.py +++ b/worlds/lufia2ac/Utils.py @@ -1,5 +1,7 @@ +import itertools +from operator import itemgetter from random import Random -from typing import Dict, List, MutableSequence, Sequence, Set, Tuple +from typing import Dict, Iterable, List, MutableSequence, Sequence, Set, Tuple def constrained_choices(population: Sequence[int], d: int, *, k: int, random: Random) -> List[int]: @@ -19,3 +21,10 @@ def constrained_shuffle(x: MutableSequence[int], d: int, random: Random) -> None i, j = random.randrange(n), random.randrange(n) if x[i] in constraints[j] and x[j] in constraints[i]: x[i], x[j] = x[j], x[i] + + +def weighted_sample(population: Iterable[int], weights: Iterable[float], k: int, *, random: Random) -> List[int]: + population, keys = zip(*((item, pow(random.random(), 1 / group_weight)) + for item, group in itertools.groupby(sorted(zip(population, weights)), key=itemgetter(0)) + if (group_weight := sum(weight for _, weight in group)))) + return sorted(population, key=dict(zip(population, keys)).__getitem__)[-k:] diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index fad7109a..acb988da 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -14,9 +14,9 @@ from .Client import L2ACSNIClient # noqa: F401 from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id from .Locations import l2ac_location_name_to_id, L2ACLocation from .Options import CapsuleStartingLevel, DefaultParty, EnemyFloorNumbers, EnemyMovementPatterns, EnemySprites, \ - ExpModifier, Goal, L2ACOptions + Goal, L2ACOptions from .Rom import get_base_rom_bytes, get_base_rom_path, L2ACDeltaPatch -from .Utils import constrained_choices, constrained_shuffle +from .Utils import constrained_choices, constrained_shuffle, weighted_sample from .basepatch import apply_basepatch CHESTS_PER_SPHERE: int = 5 @@ -222,6 +222,7 @@ class L2ACWorld(World): rom_bytearray[0x09D59B:0x09D59B + 256] = self.get_node_connection_table() rom_bytearray[0x0B05C0:0x0B05C0 + 18843] = self.get_enemy_stats() rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.o.master_hp.value.to_bytes(2, "little") + rom_bytearray[0x0BEE9F:0x0BEE9F + 1948] = self.get_shops() rom_bytearray[0x280010:0x280010 + 2] = self.o.blue_chest_count.value.to_bytes(2, "little") rom_bytearray[0x280012:0x280012 + 3] = self.o.capsule_starting_level.xp.to_bytes(3, "little") rom_bytearray[0x280015:0x280015 + 1] = self.o.initial_floor.value.to_bytes(1, "little") @@ -229,6 +230,7 @@ class L2ACWorld(World): rom_bytearray[0x280017:0x280017 + 1] = self.o.iris_treasures_required.value.to_bytes(1, "little") rom_bytearray[0x280018:0x280018 + 1] = self.o.shuffle_party_members.unlock.to_bytes(1, "little") rom_bytearray[0x280019:0x280019 + 1] = self.o.shuffle_capsule_monsters.unlock.to_bytes(1, "little") + rom_bytearray[0x28001A:0x28001A + 1] = self.o.shop_interval.value.to_bytes(1, "little") rom_bytearray[0x280030:0x280030 + 1] = self.o.goal.value.to_bytes(1, "little") rom_bytearray[0x28003D:0x28003D + 1] = self.o.death_link.value.to_bytes(1, "little") rom_bytearray[0x281200:0x281200 + 470] = self.get_capsule_cravings_table() @@ -357,7 +359,7 @@ class L2ACWorld(World): def get_enemy_stats(self) -> bytes: rom: bytes = get_base_rom_bytes() - if self.o.exp_modifier == ExpModifier.default: + if self.o.exp_modifier == 100 and self.o.gold_modifier == 100: return rom[0x0B05C0:0x0B05C0 + 18843] number_of_enemies: int = 224 @@ -366,6 +368,7 @@ class L2ACWorld(World): for enemy_id in range(number_of_enemies): pointer: int = int.from_bytes(enemy_stats[2 * enemy_id:2 * enemy_id + 2], "little") enemy_stats[pointer + 29:pointer + 31] = self.o.exp_modifier(enemy_stats[pointer + 29:pointer + 31]) + enemy_stats[pointer + 31:pointer + 33] = self.o.gold_modifier(enemy_stats[pointer + 31:pointer + 33]) return enemy_stats def get_goal_text_bytes(self) -> bytes: @@ -383,6 +386,90 @@ class L2ACWorld(World): goal_text_bytes = bytes((0x08, *b"\x03".join(line.encode("ascii") for line in goal_text), 0x00)) return goal_text_bytes + b"\x00" * (147 - len(goal_text_bytes)) + def get_shops(self) -> bytes: + rom: bytes = get_base_rom_bytes() + + if not self.o.shop_interval: + return rom[0x0BEE9F:0x0BEE9F + 1948] + + non_restorative_ids = {int.from_bytes(rom[0x0A713D + 2 * i:0x0A713D + 2 * i + 2], "little") for i in range(31)} + restorative_ids = {int.from_bytes(rom[0x08FFDC + 2 * i:0x08FFDC + 2 * i + 2], "little") for i in range(9)} + blue_ids = {int.from_bytes(rom[0x0A6EA0 + 2 * i:0x0A6EA0 + 2 * i + 2], "little") for i in range(41)} + number_of_spells: int = 35 + number_of_items: int = 467 + spells_offset: int = 0x0AFA5B + items_offset: int = 0x0B4F69 + non_restorative_list: List[List[int]] = [list() for _ in range(99)] + restorative_list: List[List[int]] = [list() for _ in range(99)] + blue_list: List[List[int]] = [list() for _ in range(99)] + spell_list: List[List[int]] = [list() for _ in range(99)] + gear_list: List[List[int]] = [list() for _ in range(99)] + weapon_list: List[List[int]] = [list() for _ in range(99)] + custom_list: List[List[int]] = [list() for _ in range(99)] + + for spell_id in range(number_of_spells): + pointer: int = int.from_bytes(rom[spells_offset + 2 * spell_id:spells_offset + 2 * spell_id + 2], "little") + value: int = int.from_bytes(rom[spells_offset + pointer + 15:spells_offset + pointer + 17], "little") + for f in range(value // 1000, 99): + spell_list[f].append(spell_id) + for item_id in range(number_of_items): + pointer = int.from_bytes(rom[items_offset + 2 * item_id:items_offset + 2 * item_id + 2], "little") + buckets: List[List[List[int]]] = list() + if item_id in non_restorative_ids: + buckets.append(non_restorative_list) + if item_id in restorative_ids: + buckets.append(restorative_list) + if item_id in blue_ids: + buckets.append(blue_list) + if not rom[items_offset + pointer] & 0x20 and not rom[items_offset + pointer + 1] & 0x20: + category: int = rom[items_offset + pointer + 7] + if category >= 0x02: + buckets.append(gear_list) + elif category == 0x01: + buckets.append(weapon_list) + if item_id in self.o.shop_inventory.custom: + buckets.append(custom_list) + value = int.from_bytes(rom[items_offset + pointer + 5:items_offset + pointer + 7], "little") + for bucket in buckets: + for f in range(value // 1000, 99): + bucket[f].append(item_id) + + if not self.o.gear_variety_after_b9: + for f in range(99): + del gear_list[f][len(gear_list[f]) % 128:] + + def create_shop(floor: int) -> Tuple[int, ...]: + if self.random.randrange(self.o.shop_inventory.total) < self.o.shop_inventory.spell: + return create_spell_shop(floor) + else: + return create_item_shop(floor) + + def create_spell_shop(floor: int) -> Tuple[int, ...]: + spells = self.random.sample(spell_list[floor], 3) + return 0x03, 0x20, 0x00, *spells, 0xFF + + def create_item_shop(floor: int) -> Tuple[int, ...]: + population = non_restorative_list[floor] + restorative_list[floor] + blue_list[floor] \ + + gear_list[floor] + weapon_list[floor] + custom_list[floor] + weights = itertools.chain(*([weight / len_] * len_ if (len_ := len(list_)) else [] for weight, list_ in + [(self.o.shop_inventory.non_restorative, non_restorative_list[floor]), + (self.o.shop_inventory.restorative, restorative_list[floor]), + (self.o.shop_inventory.blue_chest, blue_list[floor]), + (self.o.shop_inventory.gear, gear_list[floor]), + (self.o.shop_inventory.weapon, weapon_list[floor])]), + (self.o.shop_inventory.custom[item] for item in custom_list[floor])) + items = weighted_sample(population, weights, 5, random=self.random) + return 0x01, 0x04, 0x00, *(b for item in items for b in item.to_bytes(2, "little")), 0x00, 0x00 + + shops = [create_shop(floor) + for floor in range(self.o.shop_interval, 99, self.o.shop_interval) + for _ in range(self.o.shop_interval)] + shop_pointers = itertools.accumulate((len(shop) for shop in shops[:-1]), initial=2 * len(shops)) + shop_bytes = bytes(itertools.chain(*(p.to_bytes(2, "little") for p in shop_pointers), *shops)) + + assert len(shop_bytes) <= 1948, shop_bytes + return shop_bytes.ljust(1948, b"\x00") + @staticmethod def get_node_connection_table() -> bytes: class Connect(IntFlag): diff --git a/worlds/lufia2ac/basepatch/basepatch.asm b/worlds/lufia2ac/basepatch/basepatch.asm index aeae6846..923ee6a2 100644 --- a/worlds/lufia2ac/basepatch/basepatch.asm +++ b/worlds/lufia2ac/basepatch/basepatch.asm @@ -71,6 +71,11 @@ org $9EDD60 ; name org $9FA900 ; sprite incbin "ap_logo/ap_logo.bin" warnpc $9FA980 +; sold out item +org $96F9BA ; properties + DB $00,$00,$00,$10,$00,$00,$00,$00,$00,$00,$00,$00,$00 +org $9EDD6C ; name + DB "SOLD OUT " ; overwrites "Crown " org $D08000 ; signature, start of expanded data area @@ -825,6 +830,119 @@ SpellRNG: +; shops +pushpc +org $83B442 + ; DB=$83, x=1, m=1 + JSL Shop ; overwrites STA $7FD0BF +pullpc + +Shop: + STA $7FD0BF ; (overwritten instruction) + LDY $05AC ; load map number + CPY.b #$F0 ; check if ancient cave + BCC + + LDA $05B4 ; check if going to ancient cave entrance + BEQ + + LDA $7FE696 ; load next to next floor number + DEC + CPY.b #$F1 ; check if going to final floor + BCS ++ ; skip a decrement because next floor number is not incremented on final floor + DEC +++: CMP $D08015 ; check if past initial floor + BCC + + STA $4204 ; WRDIVL; dividend = floor number + STZ $4205 ; WRDIVH + TAX + LDA $D0801A + STA $4206 ; WRDIVB; divisor = shop_interval + STA $211C ; M7B; second factor = shop_interval + JSL $8082C7 ; advance RNG (while waiting for division to complete) + LDY $4216 ; RDMPYL; skip if remainder (i.e., floor number mod shop_interval) is not 0 + BNE + + STA $211B + STZ $211B ; M7A; first factor = random number from 0 to 255 + TXA + CLC + SBC $2135 ; MPYM; calculate (floor number) - (random number from 0 to shop_interval-1) - 1 + STA $30 ; set shop id + STZ $05A8 ; initialize variable for sold out item tracking + STZ $05A9 + PHB + PHP + JML $80A33A ; open shop menu ++: RTL + +; shop item select +pushpc +org $82DF50 + ; DB=$83, x=0, m=1 + JML ShopItemSelected ; overwrites JSR $8B08 : CMP.b #$01 +pullpc + +ShopItemSelected: + LDA $1548 ; check inventory free space + BEQ + + JSR LoadShopSlotAsFlag + BIT $05A8 ; test item not already sold + BNE + + JML $82DF79 ; skip quantity selection and go directly to buy/equip ++: JML $82DF80 ; abort and go back to item selection + +; track bought shop items +pushpc +org $82E084 + ; DB=$83, x=0, m=1 + JSL ShopBuy ; overwrites LDA.b #$05 : LDX.w #$0007 + NOP +org $82E10E + ; DB=$83, x=0, m=1 + JSL ShopEquip ; overwrites SEP #$10 : LDX $14DC + NOP +pullpc + +ShopBuy: + JSR LoadShopSlotAsFlag + TSB $05A8 ; mark item as sold + LDA.b #$05 ; (overwritten instruction) + LDX.w #$0007 ; (overwritten instruction) + RTL + +ShopEquip: + JSR LoadShopSlotAsFlag + TSB $05A8 ; mark item as sold + SEP #$10 ; (overwritten instruction) + LDX $14DC ; (overwritten instruction) + RTL + +LoadShopSlotAsFlag: + TDC + LDA $14EC ; load currently selected shop slot number + ASL + TAX + LDA $8ED8C3,X ; load predefined bitmask with a single bit set + RTS + +; mark bought items as sold out +pushpc +org $8285EA + ; DB=$83, x=0, m=0 + JSL SoldOut ; overwrites LDA [$FC],Y : AND #$01FF + NOP +pullpc + +SoldOut: + LDA $8ED8C3,X ; load predefined bitmask with a single bit set + BIT $05A8 ; test sold items + BEQ + + LDA.w #$01CB ; load sold out item id + BRA ++ ++: LDA [$FC],Y ; (overwritten instruction) + AND #$01FF ; (overwritten instruction) +++: RTL + + + ; increase variety of red chest gear after B9 pushpc org $839176 @@ -1054,6 +1172,7 @@ pullpc ; $F02017 1 iris treasures required ; $F02018 1 party members available ; $F02019 1 capsule monsters available +; $F0201A 1 shop interval ; $F02030 1 selected goal ; $F02031 1 goal completion: boss ; $F02032 1 goal completion: iris_treasure_hunt diff --git a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 index 51478e5d..aee1c712 100644 Binary files a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 and b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 differ diff --git a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md index 64658a7d..849a9f9c 100644 --- a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md +++ b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md @@ -49,8 +49,9 @@ Your Party Leader will hold up the item they received when not in a fight or in - Customize the multiworld item pool. (By default, your pool is filled with random blue chest items, but you can place any cave item you want instead) - Customize start inventory, i.e., begin every run with certain items or spells of your choice -- Adjust how much EXP is gained from enemies +- Adjust how much EXP and gold is gained from enemies - Randomize enemy movement patterns, enemy sprites, and which enemy types can appear at which floor numbers +- Option to make shops appear in the cave so that you have a way to spend your hard-earned gold - Option to shuffle your party members and/or capsule monsters into the multiworld, meaning that someone will have to find them in order to unlock them for you to use. While cave diving, you can add newly unlocked members to your party by using the character items from your inventory