lufia2ac: add shops to the cave (#2103)

This PR adds a new, optional aspect to the Ancient Cave experience:
During their run, players can have the opportunity to purchase some additional items or spells to improve their party. If enabled, a shop will appear everytime a certain (configurable) number of floors in the dungeon has been completed. The shop inventories are generated randomly (taking into account player preference as well as a system to ensure that more expensive items can only become available deeper into the run).

For customization, 3 new options are introduced: 
- `shop_interval`: Determines by how many floors the shops are separated (or keeps them turned off entirely)
- `shop_inventory`: Determines what's possible to be for sale. (Players can specify weights for general categories of things such as "weapon" or "spell" or even adjust the probabilities of individual items)
- `gold_modifier`: Determines how much gold is dropped by enemies. This is the player's only source of income and thus controls how much money they will have available to spend in shops
This commit is contained in:
el-u
2023-10-21 23:27:30 +02:00
committed by GitHub
parent 7c2cb34b45
commit 1c4303cce6
7 changed files with 349 additions and 10 deletions

View File

@@ -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):