SoE: use new AP API and naming and make APworld (#2701)
* SoE: new file naming also fixes test base deprecation * SoE: use options_dataclass * SoE: moar typing * SoE: no more multiworld.random * SoE: replace LogicMixin by SoEPlayerLogic object * SoE: add test that rocket parts always exist * SoE: Even moar typing * SoE: can haz apworld now * SoE: pep up test naming * SoE: use self.options for trap chances * SoE: remove unused import with outdated comment * SoE: move flag and trap extraction to dataclass as suggested by beauxq * SoE: test trap option parsing and item generation
This commit is contained in:
parent
47dd36456e
commit
e00b5a7d17
1
setup.py
1
setup.py
|
@ -76,7 +76,6 @@ non_apworlds: set = {
|
|||
"Ocarina of Time",
|
||||
"Overcooked! 2",
|
||||
"Raft",
|
||||
"Secret of Evermore",
|
||||
"Slay the Spire",
|
||||
"Sudoku",
|
||||
"Super Mario 64",
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
from typing import Protocol, Set
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from worlds.AutoWorld import LogicMixin
|
||||
from . import pyevermizer
|
||||
from .Options import EnergyCore, OutOfBounds, SequenceBreaks
|
||||
|
||||
# TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early?
|
||||
|
||||
# TODO: resolve/flatten/expand rules to get rid of recursion below where possible
|
||||
# Logic.rules are all rules including locations, excluding those with no progress (i.e. locations that only drop items)
|
||||
rules = [rule for rule in pyevermizer.get_logic() if len(rule.provides) > 0]
|
||||
# Logic.items are all items and extra items excluding non-progression items and duplicates
|
||||
item_names: Set[str] = set()
|
||||
items = [item for item in filter(lambda item: item.progression, pyevermizer.get_items() + pyevermizer.get_extra_items())
|
||||
if item.name not in item_names and not item_names.add(item.name)]
|
||||
|
||||
|
||||
class LogicProtocol(Protocol):
|
||||
def has(self, name: str, player: int) -> bool: ...
|
||||
def count(self, name: str, player: int) -> int: ...
|
||||
def soe_has(self, progress: int, world: MultiWorld, player: int, count: int) -> bool: ...
|
||||
def _soe_count(self, progress: int, world: MultiWorld, player: int, max_count: int) -> int: ...
|
||||
|
||||
|
||||
# when this module is loaded, this mixin will extend BaseClasses.CollectionState
|
||||
class SecretOfEvermoreLogic(LogicMixin):
|
||||
def _soe_count(self: LogicProtocol, progress: int, world: MultiWorld, player: int, max_count: int = 0) -> int:
|
||||
"""
|
||||
Returns reached count of one of evermizer's progress steps based on collected items.
|
||||
i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP
|
||||
"""
|
||||
n = 0
|
||||
for item in items:
|
||||
for pvd in item.provides:
|
||||
if pvd[1] == progress:
|
||||
if self.has(item.name, player):
|
||||
n += self.count(item.name, player) * pvd[0]
|
||||
if n >= max_count > 0:
|
||||
return n
|
||||
for rule in rules:
|
||||
for pvd in rule.provides:
|
||||
if pvd[1] == progress and pvd[0] > 0:
|
||||
has = True
|
||||
for req in rule.requires:
|
||||
if not self.soe_has(req[1], world, player, req[0]):
|
||||
has = False
|
||||
break
|
||||
if has:
|
||||
n += pvd[0]
|
||||
if n >= max_count > 0:
|
||||
return n
|
||||
return n
|
||||
|
||||
def soe_has(self: LogicProtocol, progress: int, world: MultiWorld, player: int, count: int = 1) -> bool:
|
||||
"""
|
||||
Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE
|
||||
"""
|
||||
if progress == pyevermizer.P_ENERGY_CORE: # logic is shared between worlds, so we override in the call
|
||||
w = world.worlds[player]
|
||||
if w.energy_core == EnergyCore.option_fragments:
|
||||
progress = pyevermizer.P_CORE_FRAGMENT
|
||||
count = w.required_fragments
|
||||
elif progress == pyevermizer.P_ALLOW_OOB:
|
||||
if world.worlds[player].out_of_bounds == OutOfBounds.option_logic:
|
||||
return True
|
||||
elif progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS:
|
||||
if world.worlds[player].sequence_breaks == SequenceBreaks.option_logic:
|
||||
return True
|
||||
return self._soe_count(progress, world, player, count) >= count
|
|
@ -4,18 +4,20 @@ import os.path
|
|||
import threading
|
||||
import typing
|
||||
|
||||
# from . import pyevermizer # as part of the source tree
|
||||
import pyevermizer # from package
|
||||
|
||||
import settings
|
||||
from BaseClasses import Item, ItemClassification, Location, LocationProgressType, Region, Tutorial
|
||||
from Utils import output_path
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from worlds.generic.Rules import add_item_rule, set_rule
|
||||
from BaseClasses import Entrance, Item, ItemClassification, Location, LocationProgressType, Region, Tutorial
|
||||
from Utils import output_path
|
||||
from .logic import SoEPlayerLogic
|
||||
from .options import AvailableFragments, Difficulty, EnergyCore, RequiredFragments, SoEOptions, TrapChance
|
||||
from .patch import SoEDeltaPatch, get_base_rom_path
|
||||
|
||||
import pyevermizer # from package
|
||||
# from . import pyevermizer # as part of the source tree
|
||||
|
||||
from . import Logic # load logic mixin
|
||||
from .Options import soe_options, Difficulty, EnergyCore, RequiredFragments, AvailableFragments
|
||||
from .Patch import SoEDeltaPatch, get_base_rom_path
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, CollectionState
|
||||
|
||||
"""
|
||||
In evermizer:
|
||||
|
@ -24,17 +26,17 @@ Items are uniquely defined by a pair of (type, id).
|
|||
For most items this is their vanilla location (i.e. CHECK_GOURD, number).
|
||||
|
||||
Items have `provides`, which give the actual progression
|
||||
instead of providing multiple events per item, we iterate through them in Logic.py
|
||||
instead of providing multiple events per item, we iterate through them in logic.py
|
||||
e.g. Found any weapon
|
||||
|
||||
Locations have `requires` and `provides`.
|
||||
Requirements have to be converted to (access) rules for AP
|
||||
e.g. Chest locked behind having a weapon
|
||||
Provides could be events, but instead we iterate through the entire logic in Logic.py
|
||||
Provides could be events, but instead we iterate through the entire logic in logic.py
|
||||
e.g. NPC available after fighting a Boss
|
||||
|
||||
Rules are special locations that don't have a physical location
|
||||
instead of implementing virtual locations and virtual items, we simply use them in Logic.py
|
||||
instead of implementing virtual locations and virtual items, we simply use them in logic.py
|
||||
e.g. 2DEs+Wheel+Gauge = Rocket
|
||||
|
||||
Rules and Locations live on the same logic tree returned by pyevermizer.get_logic()
|
||||
|
@ -84,8 +86,8 @@ _other_items = (
|
|||
)
|
||||
|
||||
|
||||
def _match_item_name(item, substr: str) -> bool:
|
||||
sub = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name
|
||||
def _match_item_name(item: pyevermizer.Item, substr: str) -> bool:
|
||||
sub: str = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name
|
||||
return sub == substr or sub == substr+'s'
|
||||
|
||||
|
||||
|
@ -158,8 +160,9 @@ class SoEWorld(World):
|
|||
Secret of Evermore is a SNES action RPG. You learn alchemy spells, fight bosses and gather rocket parts to visit a
|
||||
space station where the final boss must be defeated.
|
||||
"""
|
||||
game: str = "Secret of Evermore"
|
||||
option_definitions = soe_options
|
||||
game: typing.ClassVar[str] = "Secret of Evermore"
|
||||
options_dataclass = SoEOptions
|
||||
options: SoEOptions
|
||||
settings: typing.ClassVar[SoESettings]
|
||||
topology_present = False
|
||||
data_version = 4
|
||||
|
@ -170,31 +173,21 @@ class SoEWorld(World):
|
|||
location_name_to_id, location_id_to_raw = _get_location_mapping()
|
||||
item_name_groups = _get_item_grouping()
|
||||
|
||||
trap_types = [name[12:] for name in option_definitions if name.startswith('trap_chance_')]
|
||||
|
||||
logic: SoEPlayerLogic
|
||||
evermizer_seed: int
|
||||
connect_name: str
|
||||
energy_core: int
|
||||
sequence_breaks: int
|
||||
out_of_bounds: int
|
||||
available_fragments: int
|
||||
required_fragments: int
|
||||
|
||||
_halls_ne_chest_names: typing.List[str] = [loc.name for loc in _locations if 'Halls NE' in loc.name]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
self.connect_name_available_event = threading.Event()
|
||||
super(SoEWorld, self).__init__(*args, **kwargs)
|
||||
super(SoEWorld, self).__init__(multiworld, player)
|
||||
|
||||
def generate_early(self) -> None:
|
||||
# store option values that change logic
|
||||
self.energy_core = self.multiworld.energy_core[self.player].value
|
||||
self.sequence_breaks = self.multiworld.sequence_breaks[self.player].value
|
||||
self.out_of_bounds = self.multiworld.out_of_bounds[self.player].value
|
||||
self.required_fragments = self.multiworld.required_fragments[self.player].value
|
||||
if self.required_fragments > self.multiworld.available_fragments[self.player].value:
|
||||
self.multiworld.available_fragments[self.player].value = self.required_fragments
|
||||
self.available_fragments = self.multiworld.available_fragments[self.player].value
|
||||
# create logic from options
|
||||
if self.options.required_fragments.value > self.options.available_fragments.value:
|
||||
self.options.available_fragments.value = self.options.required_fragments.value
|
||||
self.logic = SoEPlayerLogic(self.player, self.options)
|
||||
|
||||
def create_event(self, event: str) -> Item:
|
||||
return SoEItem(event, ItemClassification.progression, None, self.player)
|
||||
|
@ -214,20 +207,20 @@ class SoEWorld(World):
|
|||
return SoEItem(item.name, classification, self.item_name_to_id[item.name], self.player)
|
||||
|
||||
@classmethod
|
||||
def stage_assert_generate(cls, multiworld):
|
||||
def stage_assert_generate(cls, _: "MultiWorld") -> None:
|
||||
rom_file = get_base_rom_path()
|
||||
if not os.path.exists(rom_file):
|
||||
raise FileNotFoundError(rom_file)
|
||||
|
||||
def create_regions(self):
|
||||
def create_regions(self) -> None:
|
||||
# exclude 'hidden' on easy
|
||||
max_difficulty = 1 if self.multiworld.difficulty[self.player] == Difficulty.option_easy else 256
|
||||
max_difficulty = 1 if self.options.difficulty == Difficulty.option_easy else 256
|
||||
|
||||
# TODO: generate *some* regions from locations' requirements?
|
||||
menu = Region('Menu', self.player, self.multiworld)
|
||||
self.multiworld.regions += [menu]
|
||||
|
||||
def get_sphere_index(evermizer_loc):
|
||||
def get_sphere_index(evermizer_loc: pyevermizer.Location) -> int:
|
||||
"""Returns 0, 1 or 2 for locations in spheres 1, 2, 3+"""
|
||||
if len(evermizer_loc.requires) == 1 and evermizer_loc.requires[0][1] != pyevermizer.P_WEAPON:
|
||||
return 2
|
||||
|
@ -252,18 +245,18 @@ class SoEWorld(World):
|
|||
# mark some as excluded based on numbers above
|
||||
for trash_sphere, fills in trash_fills.items():
|
||||
for typ, counts in fills.items():
|
||||
count = counts[self.multiworld.difficulty[self.player].value]
|
||||
for location in self.multiworld.random.sample(spheres[trash_sphere][typ], count):
|
||||
count = counts[self.options.difficulty.value]
|
||||
for location in self.random.sample(spheres[trash_sphere][typ], count):
|
||||
assert location.name != "Energy Core #285", "Error in sphere generation"
|
||||
location.progress_type = LocationProgressType.EXCLUDED
|
||||
|
||||
def sphere1_blocked_items_rule(item):
|
||||
def sphere1_blocked_items_rule(item: pyevermizer.Item) -> bool:
|
||||
if isinstance(item, SoEItem):
|
||||
# disable certain items in sphere 1
|
||||
if item.name in {"Gauge", "Wheel"}:
|
||||
return False
|
||||
# and some more for non-easy, non-mystery
|
||||
if self.multiworld.difficulty[item.player] not in (Difficulty.option_easy, Difficulty.option_mystery):
|
||||
if self.options.difficulty not in (Difficulty.option_easy, Difficulty.option_mystery):
|
||||
if item.name in {"Laser Lance", "Atom Smasher", "Diamond Eye"}:
|
||||
return False
|
||||
return True
|
||||
|
@ -273,13 +266,13 @@ class SoEWorld(World):
|
|||
add_item_rule(location, sphere1_blocked_items_rule)
|
||||
|
||||
# make some logically late(r) bosses priority locations to increase complexity
|
||||
if self.multiworld.difficulty[self.player] == Difficulty.option_mystery:
|
||||
late_count = self.multiworld.random.randint(0, 2)
|
||||
if self.options.difficulty == Difficulty.option_mystery:
|
||||
late_count = self.random.randint(0, 2)
|
||||
else:
|
||||
late_count = self.multiworld.difficulty[self.player].value
|
||||
late_count = self.options.difficulty.value
|
||||
late_bosses = ("Tiny", "Aquagoth", "Megataur", "Rimsala",
|
||||
"Mungola", "Lightning Storm", "Magmar", "Volcano Viper")
|
||||
late_locations = self.multiworld.random.sample(late_bosses, late_count)
|
||||
late_locations = self.random.sample(late_bosses, late_count)
|
||||
|
||||
# add locations to the world
|
||||
for sphere in spheres.values():
|
||||
|
@ -293,17 +286,17 @@ class SoEWorld(World):
|
|||
menu.connect(ingame, "New Game")
|
||||
self.multiworld.regions += [ingame]
|
||||
|
||||
def create_items(self):
|
||||
def create_items(self) -> None:
|
||||
# add regular items to the pool
|
||||
exclusions: typing.List[str] = []
|
||||
if self.energy_core != EnergyCore.option_shuffle:
|
||||
if self.options.energy_core != EnergyCore.option_shuffle:
|
||||
exclusions.append("Energy Core") # will be placed in generate_basic or replaced by a fragment below
|
||||
items = list(map(lambda item: self.create_item(item), (item for item in _items if item.name not in exclusions)))
|
||||
|
||||
# remove one pair of wings that will be placed in generate_basic
|
||||
items.remove(self.create_item("Wings"))
|
||||
|
||||
def is_ingredient(item):
|
||||
def is_ingredient(item: pyevermizer.Item) -> bool:
|
||||
for ingredient in _ingredients:
|
||||
if _match_item_name(item, ingredient):
|
||||
return True
|
||||
|
@ -311,84 +304,74 @@ class SoEWorld(World):
|
|||
|
||||
# add energy core fragments to the pool
|
||||
ingredients = [n for n, item in enumerate(items) if is_ingredient(item)]
|
||||
if self.energy_core == EnergyCore.option_fragments:
|
||||
if self.options.energy_core == EnergyCore.option_fragments:
|
||||
items.append(self.create_item("Energy Core Fragment")) # replaces the vanilla energy core
|
||||
for _ in range(self.available_fragments - 1):
|
||||
for _ in range(self.options.available_fragments - 1):
|
||||
if len(ingredients) < 1:
|
||||
break # out of ingredients to replace
|
||||
r = self.multiworld.random.choice(ingredients)
|
||||
r = self.random.choice(ingredients)
|
||||
ingredients.remove(r)
|
||||
items[r] = self.create_item("Energy Core Fragment")
|
||||
|
||||
# add traps to the pool
|
||||
trap_count = self.multiworld.trap_count[self.player].value
|
||||
trap_chances = {}
|
||||
trap_names = {}
|
||||
trap_count = self.options.trap_count.value
|
||||
trap_names: typing.List[str] = []
|
||||
trap_weights: typing.List[int] = []
|
||||
if trap_count > 0:
|
||||
for trap_type in self.trap_types:
|
||||
trap_option = getattr(self.multiworld, f'trap_chance_{trap_type}')[self.player]
|
||||
trap_chances[trap_type] = trap_option.value
|
||||
trap_names[trap_type] = trap_option.item_name
|
||||
trap_chances_total = sum(trap_chances.values())
|
||||
if trap_chances_total == 0:
|
||||
for trap_type in trap_chances:
|
||||
trap_chances[trap_type] = 1
|
||||
trap_chances_total = len(trap_chances)
|
||||
for trap_option in self.options.trap_chances:
|
||||
trap_names.append(trap_option.item_name)
|
||||
trap_weights.append(trap_option.value)
|
||||
if sum(trap_weights) == 0:
|
||||
trap_weights = [1 for _ in trap_weights]
|
||||
|
||||
def create_trap() -> Item:
|
||||
v = self.multiworld.random.randrange(trap_chances_total)
|
||||
for t, c in trap_chances.items():
|
||||
if v < c:
|
||||
return self.create_item(trap_names[t])
|
||||
v -= c
|
||||
assert False, "Bug in create_trap"
|
||||
return self.create_item(self.random.choices(trap_names, trap_weights)[0])
|
||||
|
||||
for _ in range(trap_count):
|
||||
if len(ingredients) < 1:
|
||||
break # out of ingredients to replace
|
||||
r = self.multiworld.random.choice(ingredients)
|
||||
r = self.random.choice(ingredients)
|
||||
ingredients.remove(r)
|
||||
items[r] = create_trap()
|
||||
|
||||
self.multiworld.itempool += items
|
||||
|
||||
def set_rules(self):
|
||||
def set_rules(self) -> None:
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has('Victory', self.player)
|
||||
# set Done from goal option once we have multiple goals
|
||||
set_rule(self.multiworld.get_location('Done', self.player),
|
||||
lambda state: state.soe_has(pyevermizer.P_FINAL_BOSS, self.multiworld, self.player))
|
||||
lambda state: self.logic.has(state, pyevermizer.P_FINAL_BOSS))
|
||||
set_rule(self.multiworld.get_entrance('New Game', self.player), lambda state: True)
|
||||
for loc in _locations:
|
||||
location = self.multiworld.get_location(loc.name, self.player)
|
||||
set_rule(location, self.make_rule(loc.requires))
|
||||
|
||||
def make_rule(self, requires: typing.List[typing.Tuple[int, int]]) -> typing.Callable[[typing.Any], bool]:
|
||||
def rule(state) -> bool:
|
||||
def rule(state: "CollectionState") -> bool:
|
||||
for count, progress in requires:
|
||||
if not state.soe_has(progress, self.multiworld, self.player, count):
|
||||
if not self.logic.has(state, progress, count):
|
||||
return False
|
||||
return True
|
||||
|
||||
return rule
|
||||
|
||||
def make_item_type_limit_rule(self, item_type: int):
|
||||
return lambda item: item.player != self.player or self.item_id_to_raw[item.code].type == item_type
|
||||
|
||||
def generate_basic(self):
|
||||
def generate_basic(self) -> None:
|
||||
# place Victory event
|
||||
self.multiworld.get_location('Done', self.player).place_locked_item(self.create_event('Victory'))
|
||||
# place wings in halls NE to avoid softlock
|
||||
wings_location = self.multiworld.random.choice(self._halls_ne_chest_names)
|
||||
wings_location = self.random.choice(self._halls_ne_chest_names)
|
||||
wings_item = self.create_item('Wings')
|
||||
self.multiworld.get_location(wings_location, self.player).place_locked_item(wings_item)
|
||||
# place energy core at vanilla location for vanilla mode
|
||||
if self.energy_core == EnergyCore.option_vanilla:
|
||||
if self.options.energy_core == EnergyCore.option_vanilla:
|
||||
energy_core = self.create_item('Energy Core')
|
||||
self.multiworld.get_location('Energy Core #285', self.player).place_locked_item(energy_core)
|
||||
# generate stuff for later
|
||||
self.evermizer_seed = self.multiworld.random.randint(0, 2 ** 16 - 1) # TODO: make this an option for "full" plando?
|
||||
self.evermizer_seed = self.random.randint(0, 2 ** 16 - 1) # TODO: make this an option for "full" plando?
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
from dataclasses import asdict
|
||||
|
||||
def generate_output(self, output_directory: str):
|
||||
player_name = self.multiworld.get_player_name(self.player)
|
||||
self.connect_name = player_name[:32]
|
||||
while len(self.connect_name.encode('utf-8')) > 32:
|
||||
|
@ -397,24 +380,21 @@ class SoEWorld(World):
|
|||
placement_file = ""
|
||||
out_file = ""
|
||||
try:
|
||||
money = self.multiworld.money_modifier[self.player].value
|
||||
exp = self.multiworld.exp_modifier[self.player].value
|
||||
money = self.options.money_modifier.value
|
||||
exp = self.options.exp_modifier.value
|
||||
switches: typing.List[str] = []
|
||||
if self.multiworld.death_link[self.player].value:
|
||||
if self.options.death_link.value:
|
||||
switches.append("--death-link")
|
||||
if self.energy_core == EnergyCore.option_fragments:
|
||||
switches.extend(('--available-fragments', str(self.available_fragments),
|
||||
'--required-fragments', str(self.required_fragments)))
|
||||
if self.options.energy_core == EnergyCore.option_fragments:
|
||||
switches.extend(('--available-fragments', str(self.options.available_fragments.value),
|
||||
'--required-fragments', str(self.options.required_fragments.value)))
|
||||
rom_file = get_base_rom_path()
|
||||
out_base = output_path(output_directory, self.multiworld.get_out_file_name_base(self.player))
|
||||
out_file = out_base + '.sfc'
|
||||
placement_file = out_base + '.txt'
|
||||
patch_file = out_base + '.apsoe'
|
||||
flags = 'l' # spoiler log
|
||||
for option_name in self.option_definitions:
|
||||
option = getattr(self.multiworld, option_name)[self.player]
|
||||
if hasattr(option, 'to_flag'):
|
||||
flags += option.to_flag()
|
||||
flags += self.options.flags
|
||||
|
||||
with open(placement_file, "wb") as f: # generate placement file
|
||||
for location in self.multiworld.get_locations(self.player):
|
||||
|
@ -448,7 +428,7 @@ class SoEWorld(World):
|
|||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def modify_multidata(self, multidata: dict):
|
||||
def modify_multidata(self, multidata: typing.Dict[str, typing.Any]) -> None:
|
||||
# wait for self.connect_name to be available.
|
||||
self.connect_name_available_event.wait()
|
||||
# we skip in case of error, so that the original error in the output thread is the one that gets raised
|
||||
|
@ -457,7 +437,7 @@ class SoEWorld(World):
|
|||
multidata["connect_names"][self.connect_name] = payload
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.multiworld.random.choice(list(self.item_name_groups["Ingredients"]))
|
||||
return self.random.choice(list(self.item_name_groups["Ingredients"]))
|
||||
|
||||
|
||||
class SoEItem(Item):
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import typing
|
||||
from typing import Callable, Set
|
||||
|
||||
from . import pyevermizer
|
||||
from .options import EnergyCore, OutOfBounds, SequenceBreaks, SoEOptions
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
# TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early?
|
||||
|
||||
# TODO: resolve/flatten/expand rules to get rid of recursion below where possible
|
||||
# Logic.rules are all rules including locations, excluding those with no progress (i.e. locations that only drop items)
|
||||
rules = [rule for rule in pyevermizer.get_logic() if len(rule.provides) > 0]
|
||||
# Logic.items are all items and extra items excluding non-progression items and duplicates
|
||||
item_names: Set[str] = set()
|
||||
items = [item for item in filter(lambda item: item.progression, pyevermizer.get_items() + pyevermizer.get_extra_items())
|
||||
if item.name not in item_names and not item_names.add(item.name)] # type: ignore[func-returns-value]
|
||||
|
||||
|
||||
class SoEPlayerLogic:
|
||||
__slots__ = "player", "out_of_bounds", "sequence_breaks", "has"
|
||||
player: int
|
||||
out_of_bounds: bool
|
||||
sequence_breaks: bool
|
||||
|
||||
has: Callable[..., bool]
|
||||
"""
|
||||
Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE
|
||||
"""
|
||||
|
||||
def __init__(self, player: int, options: "SoEOptions"):
|
||||
self.player = player
|
||||
self.out_of_bounds = options.out_of_bounds == OutOfBounds.option_logic
|
||||
self.sequence_breaks = options.sequence_breaks == SequenceBreaks.option_logic
|
||||
|
||||
if options.energy_core == EnergyCore.option_fragments:
|
||||
# override logic for energy core fragments
|
||||
required_fragments = options.required_fragments.value
|
||||
|
||||
def fragmented_has(state: "CollectionState", progress: int, count: int = 1) -> bool:
|
||||
if progress == pyevermizer.P_ENERGY_CORE:
|
||||
progress = pyevermizer.P_CORE_FRAGMENT
|
||||
count = required_fragments
|
||||
return self._has(state, progress, count)
|
||||
|
||||
self.has = fragmented_has
|
||||
else:
|
||||
# default (energy core) logic
|
||||
self.has = self._has
|
||||
|
||||
def _count(self, state: "CollectionState", progress: int, max_count: int = 0) -> int:
|
||||
"""
|
||||
Returns reached count of one of evermizer's progress steps based on collected items.
|
||||
i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP
|
||||
"""
|
||||
n = 0
|
||||
for item in items:
|
||||
for pvd in item.provides:
|
||||
if pvd[1] == progress:
|
||||
if state.has(item.name, self.player):
|
||||
n += state.count(item.name, self.player) * pvd[0]
|
||||
if n >= max_count > 0:
|
||||
return n
|
||||
for rule in rules:
|
||||
for pvd in rule.provides:
|
||||
if pvd[1] == progress and pvd[0] > 0:
|
||||
has = True
|
||||
for req in rule.requires:
|
||||
if not self.has(state, req[1], req[0]):
|
||||
has = False
|
||||
break
|
||||
if has:
|
||||
n += pvd[0]
|
||||
if n >= max_count > 0:
|
||||
return n
|
||||
return n
|
||||
|
||||
def _has(self, state: "CollectionState", progress: int, count: int = 1) -> bool:
|
||||
"""Default implementation of has"""
|
||||
if self.out_of_bounds is True and progress == pyevermizer.P_ALLOW_OOB:
|
||||
return True
|
||||
if self.sequence_breaks is True and progress == pyevermizer.P_ALLOW_SEQUENCE_BREAKS:
|
||||
return True
|
||||
return self._count(state, progress, count) >= count
|
|
@ -1,16 +1,18 @@
|
|||
import typing
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, cast, Dict, Iterator, List, Tuple, Protocol
|
||||
|
||||
from Options import Range, Choice, Toggle, DefaultOnToggle, AssembleOptions, DeathLink, ProgressionBalancing
|
||||
from Options import AssembleOptions, Choice, DeathLink, DefaultOnToggle, PerGameCommonOptions, ProgressionBalancing, \
|
||||
Range, Toggle
|
||||
|
||||
|
||||
# typing boilerplate
|
||||
class FlagsProtocol(typing.Protocol):
|
||||
class FlagsProtocol(Protocol):
|
||||
value: int
|
||||
default: int
|
||||
flags: typing.List[str]
|
||||
flags: List[str]
|
||||
|
||||
|
||||
class FlagProtocol(typing.Protocol):
|
||||
class FlagProtocol(Protocol):
|
||||
value: int
|
||||
default: int
|
||||
flag: str
|
||||
|
@ -18,7 +20,7 @@ class FlagProtocol(typing.Protocol):
|
|||
|
||||
# meta options
|
||||
class EvermizerFlags:
|
||||
flags: typing.List[str]
|
||||
flags: List[str]
|
||||
|
||||
def to_flag(self: FlagsProtocol) -> str:
|
||||
return self.flags[self.value]
|
||||
|
@ -200,13 +202,13 @@ class TrapCount(Range):
|
|||
|
||||
# more meta options
|
||||
class ItemChanceMeta(AssembleOptions):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[Any, Any]) -> "ItemChanceMeta":
|
||||
if 'item_name' in attrs:
|
||||
attrs["display_name"] = f"{attrs['item_name']} Chance"
|
||||
attrs["range_start"] = 0
|
||||
attrs["range_end"] = 100
|
||||
|
||||
return super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
|
||||
cls = super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
|
||||
return cast(ItemChanceMeta, cls)
|
||||
|
||||
|
||||
class TrapChance(Range, metaclass=ItemChanceMeta):
|
||||
|
@ -247,33 +249,50 @@ class SoEProgressionBalancing(ProgressionBalancing):
|
|||
special_range_names = {**ProgressionBalancing.special_range_names, "normal": default}
|
||||
|
||||
|
||||
soe_options: typing.Dict[str, AssembleOptions] = {
|
||||
"difficulty": Difficulty,
|
||||
"energy_core": EnergyCore,
|
||||
"required_fragments": RequiredFragments,
|
||||
"available_fragments": AvailableFragments,
|
||||
"money_modifier": MoneyModifier,
|
||||
"exp_modifier": ExpModifier,
|
||||
"sequence_breaks": SequenceBreaks,
|
||||
"out_of_bounds": OutOfBounds,
|
||||
"fix_cheats": FixCheats,
|
||||
"fix_infinite_ammo": FixInfiniteAmmo,
|
||||
"fix_atlas_glitch": FixAtlasGlitch,
|
||||
"fix_wings_glitch": FixWingsGlitch,
|
||||
"shorter_dialogs": ShorterDialogs,
|
||||
"short_boss_rush": ShortBossRush,
|
||||
"ingredienizer": Ingredienizer,
|
||||
"sniffamizer": Sniffamizer,
|
||||
"callbeadamizer": Callbeadamizer,
|
||||
"musicmizer": Musicmizer,
|
||||
"doggomizer": Doggomizer,
|
||||
"turdo_mode": TurdoMode,
|
||||
"death_link": DeathLink,
|
||||
"trap_count": TrapCount,
|
||||
"trap_chance_quake": TrapChanceQuake,
|
||||
"trap_chance_poison": TrapChancePoison,
|
||||
"trap_chance_confound": TrapChanceConfound,
|
||||
"trap_chance_hud": TrapChanceHUD,
|
||||
"trap_chance_ohko": TrapChanceOHKO,
|
||||
"progression_balancing": SoEProgressionBalancing,
|
||||
}
|
||||
# noinspection SpellCheckingInspection
|
||||
@dataclass
|
||||
class SoEOptions(PerGameCommonOptions):
|
||||
difficulty: Difficulty
|
||||
energy_core: EnergyCore
|
||||
required_fragments: RequiredFragments
|
||||
available_fragments: AvailableFragments
|
||||
money_modifier: MoneyModifier
|
||||
exp_modifier: ExpModifier
|
||||
sequence_breaks: SequenceBreaks
|
||||
out_of_bounds: OutOfBounds
|
||||
fix_cheats: FixCheats
|
||||
fix_infinite_ammo: FixInfiniteAmmo
|
||||
fix_atlas_glitch: FixAtlasGlitch
|
||||
fix_wings_glitch: FixWingsGlitch
|
||||
shorter_dialogs: ShorterDialogs
|
||||
short_boss_rush: ShortBossRush
|
||||
ingredienizer: Ingredienizer
|
||||
sniffamizer: Sniffamizer
|
||||
callbeadamizer: Callbeadamizer
|
||||
musicmizer: Musicmizer
|
||||
doggomizer: Doggomizer
|
||||
turdo_mode: TurdoMode
|
||||
death_link: DeathLink
|
||||
trap_count: TrapCount
|
||||
trap_chance_quake: TrapChanceQuake
|
||||
trap_chance_poison: TrapChancePoison
|
||||
trap_chance_confound: TrapChanceConfound
|
||||
trap_chance_hud: TrapChanceHUD
|
||||
trap_chance_ohko: TrapChanceOHKO
|
||||
progression_balancing: SoEProgressionBalancing
|
||||
|
||||
@property
|
||||
def trap_chances(self) -> Iterator[TrapChance]:
|
||||
for field in fields(self):
|
||||
option = getattr(self, field.name)
|
||||
if isinstance(option, TrapChance):
|
||||
yield option
|
||||
|
||||
@property
|
||||
def flags(self) -> str:
|
||||
flags = ''
|
||||
for field in fields(self):
|
||||
option = getattr(self, field.name)
|
||||
if isinstance(option, (EvermizerFlag, EvermizerFlags)):
|
||||
flags += getattr(self, field.name).to_flag()
|
||||
return flags
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
from typing import Optional
|
||||
from typing import BinaryIO, Optional
|
||||
|
||||
import Utils
|
||||
from worlds.Files import APDeltaPatch
|
||||
|
@ -30,7 +30,7 @@ def get_base_rom_path(file_name: Optional[str] = None) -> str:
|
|||
return file_name
|
||||
|
||||
|
||||
def read_rom(stream, strip_header=True) -> bytes:
|
||||
def read_rom(stream: BinaryIO, strip_header: bool=True) -> bytes:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
data = stream.read()
|
||||
if strip_header and len(data) % 0x400 == 0x200:
|
||||
|
@ -40,5 +40,5 @@ def read_rom(stream, strip_header=True) -> bytes:
|
|||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
print('Please use ../../Patch.py', file=sys.stderr)
|
||||
print('Please use ../../patch.py', file=sys.stderr)
|
||||
sys.exit(1)
|
|
@ -1,4 +1,4 @@
|
|||
from test.TestBase import WorldTestBase
|
||||
from test.bases import WorldTestBase
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
|
@ -18,3 +18,14 @@ class SoETestBase(WorldTestBase):
|
|||
for location in unreachable:
|
||||
self.assertFalse(self.can_reach_location(location),
|
||||
f"{location} is reachable but shouldn't be")
|
||||
|
||||
def testRocketPartsExist(self):
|
||||
"""Tests that rocket parts exist and are unique"""
|
||||
self.assertEqual(len(self.get_items_by_name("Gauge")), 1)
|
||||
self.assertEqual(len(self.get_items_by_name("Wheel")), 1)
|
||||
diamond_eyes = self.get_items_by_name("Diamond Eye")
|
||||
self.assertEqual(len(diamond_eyes), 3)
|
||||
# verify diamond eyes are individual items
|
||||
self.assertFalse(diamond_eyes[0] is diamond_eyes[1])
|
||||
self.assertFalse(diamond_eyes[0] is diamond_eyes[2])
|
||||
self.assertFalse(diamond_eyes[1] is diamond_eyes[2])
|
||||
|
|
|
@ -7,7 +7,7 @@ class AccessTest(SoETestBase):
|
|||
def _resolveGourds(gourds: typing.Dict[str, typing.Iterable[int]]):
|
||||
return [f"{name} #{number}" for name, numbers in gourds.items() for number in numbers]
|
||||
|
||||
def testBronzeAxe(self):
|
||||
def test_bronze_axe(self):
|
||||
gourds = {
|
||||
"Pyramid bottom": (118, 121, 122, 123, 124, 125),
|
||||
"Pyramid top": (140,)
|
||||
|
@ -16,7 +16,7 @@ class AccessTest(SoETestBase):
|
|||
items = [["Bronze Axe"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
def testBronzeSpearPlus(self):
|
||||
def test_bronze_spear_plus(self):
|
||||
locations = ["Megataur"]
|
||||
items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]]
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
|
|
@ -8,7 +8,7 @@ class TestFragmentGoal(SoETestBase):
|
|||
"required_fragments": 20,
|
||||
}
|
||||
|
||||
def testFragments(self):
|
||||
def test_fragments(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||
self.assertBeatable(False) # 0 fragments
|
||||
fragments = self.get_items_by_name("Energy Core Fragment")
|
||||
|
@ -24,11 +24,11 @@ class TestFragmentGoal(SoETestBase):
|
|||
self.assertEqual(self.count("Energy Core Fragment"), 21)
|
||||
self.assertBeatable(True)
|
||||
|
||||
def testNoWeapon(self):
|
||||
def test_no_weapon(self):
|
||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core Fragment"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def testNoRocket(self):
|
||||
def test_no_rocket(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core Fragment"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
|
@ -38,16 +38,16 @@ class TestShuffleGoal(SoETestBase):
|
|||
"energy_core": "shuffle",
|
||||
}
|
||||
|
||||
def testCore(self):
|
||||
def test_core(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name(["Energy Core"])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def testNoWeapon(self):
|
||||
def test_no_weapon(self):
|
||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def testNoRocket(self):
|
||||
def test_no_rocket(self):
|
||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core"])
|
||||
self.assertBeatable(False)
|
||||
|
|
|
@ -6,7 +6,7 @@ class OoBTest(SoETestBase):
|
|||
"""Tests that 'on' doesn't put out-of-bounds in logic. This is also the test base for OoB in logic."""
|
||||
options: typing.Dict[str, typing.Any] = {"out_of_bounds": "on"}
|
||||
|
||||
def testOoBAccess(self):
|
||||
def test_oob_access(self):
|
||||
in_logic = self.options["out_of_bounds"] == "logic"
|
||||
|
||||
# some locations that just need a weapon + OoB
|
||||
|
@ -37,7 +37,7 @@ class OoBTest(SoETestBase):
|
|||
self.collect_by_name("Diamond Eye")
|
||||
self.assertLocationReachability(reachable=de_reachable, unreachable=de_unreachable, satisfied=in_logic)
|
||||
|
||||
def testOoBGoal(self):
|
||||
def test_oob_goal(self):
|
||||
# still need Energy Core with OoB if sequence breaks are not in logic
|
||||
for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]:
|
||||
self.collect_by_name(item)
|
||||
|
|
|
@ -6,7 +6,7 @@ class SequenceBreaksTest(SoETestBase):
|
|||
"""Tests that 'on' doesn't put sequence breaks in logic. This is also the test base for in-logic."""
|
||||
options: typing.Dict[str, typing.Any] = {"sequence_breaks": "on"}
|
||||
|
||||
def testSequenceBreaksAccess(self):
|
||||
def test_sequence_breaks_access(self):
|
||||
in_logic = self.options["sequence_breaks"] == "logic"
|
||||
|
||||
# some locations that just need any weapon + sequence break
|
||||
|
@ -30,7 +30,7 @@ class SequenceBreaksTest(SoETestBase):
|
|||
self.collect_by_name("Bronze Spear") # Escape now just needs either Megataur or Rimsala dead
|
||||
self.assertEqual(self.can_reach_location("Escape"), in_logic)
|
||||
|
||||
def testSequenceBreaksGoal(self):
|
||||
def test_sequence_breaks_goal(self):
|
||||
in_logic = self.options["sequence_breaks"] == "logic"
|
||||
|
||||
# don't need Energy Core with sequence breaks in logic
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import typing
|
||||
from dataclasses import fields
|
||||
|
||||
from . import SoETestBase
|
||||
from ..options import SoEOptions
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .. import SoEWorld
|
||||
|
||||
|
||||
class Bases:
|
||||
# class in class to avoid running tests for TrapTest class
|
||||
class TrapTestBase(SoETestBase):
|
||||
"""Test base for trap tests"""
|
||||
option_name_to_item_name = {
|
||||
# filtering by name here validates that there is no confusion between name and type
|
||||
field.name: field.type.item_name for field in fields(SoEOptions) if field.name.startswith("trap_chance_")
|
||||
}
|
||||
|
||||
def test_dataclass(self) -> None:
|
||||
"""Test that the dataclass helper property returns the expected sequence"""
|
||||
self.assertGreater(len(self.option_name_to_item_name), 0, "Expected more than 0 trap types")
|
||||
world: "SoEWorld" = typing.cast("SoEWorld", self.multiworld.worlds[1])
|
||||
item_name_to_rolled_option = {option.item_name: option for option in world.options.trap_chances}
|
||||
# compare that all fields are present - that is property in dataclass and selector code in test line up
|
||||
self.assertEqual(sorted(self.option_name_to_item_name.values()), sorted(item_name_to_rolled_option),
|
||||
"field names probably do not match field types")
|
||||
# sanity check that chances are correctly set and returned by property
|
||||
for option_name, item_name in self.option_name_to_item_name.items():
|
||||
self.assertEqual(item_name_to_rolled_option[item_name].value,
|
||||
self.options.get(option_name, item_name_to_rolled_option[item_name].default))
|
||||
|
||||
def test_trap_count(self) -> None:
|
||||
"""Test that total trap count is correct"""
|
||||
self.assertEqual(self.options["trap_count"], len(self.get_items_by_name(self.option_name_to_item_name.values())))
|
||||
|
||||
|
||||
class TestTrapAllZeroChance(Bases.TrapTestBase):
|
||||
"""Tests all zero chances still gives traps if trap_count is set."""
|
||||
options: typing.Dict[str, typing.Any] = {
|
||||
"trap_count": 1,
|
||||
**{name: 0 for name in Bases.TrapTestBase.option_name_to_item_name}
|
||||
}
|
||||
|
||||
|
||||
class TestTrapNoConfound(Bases.TrapTestBase):
|
||||
"""Tests that one zero chance does not give that trap."""
|
||||
options: typing.Dict[str, typing.Any] = {
|
||||
"trap_count": 99,
|
||||
"trap_chance_confound": 0,
|
||||
}
|
||||
|
||||
def test_no_confound_trap(self) -> None:
|
||||
self.assertEqual(self.option_name_to_item_name["trap_chance_confound"], "Confound Trap")
|
||||
self.assertEqual(len(self.get_items_by_name("Confound Trap")), 0)
|
Loading…
Reference in New Issue