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

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