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:
black-sliver 2024-01-12 01:07:40 +01:00 committed by GitHub
parent 47dd36456e
commit e00b5a7d17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 298 additions and 219 deletions

View File

@ -76,7 +76,6 @@ non_apworlds: set = {
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Secret of Evermore",
"Slay the Spire",
"Sudoku",
"Super Mario 64",

View File

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

View File

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

85
worlds/soe/logic.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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