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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 349 additions and 10 deletions

View File

@ -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"

View File

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

View File

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

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

View File

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

View File

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