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",
|
"Ocarina of Time",
|
||||||
"Overcooked! 2",
|
"Overcooked! 2",
|
||||||
"Raft",
|
"Raft",
|
||||||
"Secret of Evermore",
|
|
||||||
"Slay the Spire",
|
"Slay the Spire",
|
||||||
"Sudoku",
|
"Sudoku",
|
||||||
"Super Mario 64",
|
"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 threading
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
# from . import pyevermizer # as part of the source tree
|
||||||
|
import pyevermizer # from package
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
|
from BaseClasses import Item, ItemClassification, Location, LocationProgressType, Region, Tutorial
|
||||||
|
from Utils import output_path
|
||||||
from worlds.AutoWorld import WebWorld, World
|
from worlds.AutoWorld import WebWorld, World
|
||||||
from worlds.generic.Rules import add_item_rule, set_rule
|
from worlds.generic.Rules import add_item_rule, set_rule
|
||||||
from BaseClasses import Entrance, Item, ItemClassification, Location, LocationProgressType, Region, Tutorial
|
from .logic import SoEPlayerLogic
|
||||||
from Utils import output_path
|
from .options import AvailableFragments, Difficulty, EnergyCore, RequiredFragments, SoEOptions, TrapChance
|
||||||
|
from .patch import SoEDeltaPatch, get_base_rom_path
|
||||||
|
|
||||||
import pyevermizer # from package
|
if typing.TYPE_CHECKING:
|
||||||
# from . import pyevermizer # as part of the source tree
|
from BaseClasses import MultiWorld, CollectionState
|
||||||
|
|
||||||
from . import Logic # load logic mixin
|
|
||||||
from .Options import soe_options, Difficulty, EnergyCore, RequiredFragments, AvailableFragments
|
|
||||||
from .Patch import SoEDeltaPatch, get_base_rom_path
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
In evermizer:
|
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).
|
For most items this is their vanilla location (i.e. CHECK_GOURD, number).
|
||||||
|
|
||||||
Items have `provides`, which give the actual progression
|
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
|
e.g. Found any weapon
|
||||||
|
|
||||||
Locations have `requires` and `provides`.
|
Locations have `requires` and `provides`.
|
||||||
Requirements have to be converted to (access) rules for AP
|
Requirements have to be converted to (access) rules for AP
|
||||||
e.g. Chest locked behind having a weapon
|
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
|
e.g. NPC available after fighting a Boss
|
||||||
|
|
||||||
Rules are special locations that don't have a physical location
|
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
|
e.g. 2DEs+Wheel+Gauge = Rocket
|
||||||
|
|
||||||
Rules and Locations live on the same logic tree returned by pyevermizer.get_logic()
|
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:
|
def _match_item_name(item: pyevermizer.Item, substr: str) -> bool:
|
||||||
sub = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name
|
sub: str = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name
|
||||||
return sub == substr or sub == substr+'s'
|
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
|
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.
|
space station where the final boss must be defeated.
|
||||||
"""
|
"""
|
||||||
game: str = "Secret of Evermore"
|
game: typing.ClassVar[str] = "Secret of Evermore"
|
||||||
option_definitions = soe_options
|
options_dataclass = SoEOptions
|
||||||
|
options: SoEOptions
|
||||||
settings: typing.ClassVar[SoESettings]
|
settings: typing.ClassVar[SoESettings]
|
||||||
topology_present = False
|
topology_present = False
|
||||||
data_version = 4
|
data_version = 4
|
||||||
|
@ -170,31 +173,21 @@ class SoEWorld(World):
|
||||||
location_name_to_id, location_id_to_raw = _get_location_mapping()
|
location_name_to_id, location_id_to_raw = _get_location_mapping()
|
||||||
item_name_groups = _get_item_grouping()
|
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
|
evermizer_seed: int
|
||||||
connect_name: str
|
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]
|
_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()
|
self.connect_name_available_event = threading.Event()
|
||||||
super(SoEWorld, self).__init__(*args, **kwargs)
|
super(SoEWorld, self).__init__(multiworld, player)
|
||||||
|
|
||||||
def generate_early(self) -> None:
|
def generate_early(self) -> None:
|
||||||
# store option values that change logic
|
# create logic from options
|
||||||
self.energy_core = self.multiworld.energy_core[self.player].value
|
if self.options.required_fragments.value > self.options.available_fragments.value:
|
||||||
self.sequence_breaks = self.multiworld.sequence_breaks[self.player].value
|
self.options.available_fragments.value = self.options.required_fragments.value
|
||||||
self.out_of_bounds = self.multiworld.out_of_bounds[self.player].value
|
self.logic = SoEPlayerLogic(self.player, self.options)
|
||||||
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
|
|
||||||
|
|
||||||
def create_event(self, event: str) -> Item:
|
def create_event(self, event: str) -> Item:
|
||||||
return SoEItem(event, ItemClassification.progression, None, self.player)
|
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)
|
return SoEItem(item.name, classification, self.item_name_to_id[item.name], self.player)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def stage_assert_generate(cls, multiworld):
|
def stage_assert_generate(cls, _: "MultiWorld") -> None:
|
||||||
rom_file = get_base_rom_path()
|
rom_file = get_base_rom_path()
|
||||||
if not os.path.exists(rom_file):
|
if not os.path.exists(rom_file):
|
||||||
raise FileNotFoundError(rom_file)
|
raise FileNotFoundError(rom_file)
|
||||||
|
|
||||||
def create_regions(self):
|
def create_regions(self) -> None:
|
||||||
# exclude 'hidden' on easy
|
# 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?
|
# TODO: generate *some* regions from locations' requirements?
|
||||||
menu = Region('Menu', self.player, self.multiworld)
|
menu = Region('Menu', self.player, self.multiworld)
|
||||||
self.multiworld.regions += [menu]
|
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+"""
|
"""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:
|
if len(evermizer_loc.requires) == 1 and evermizer_loc.requires[0][1] != pyevermizer.P_WEAPON:
|
||||||
return 2
|
return 2
|
||||||
|
@ -252,18 +245,18 @@ class SoEWorld(World):
|
||||||
# mark some as excluded based on numbers above
|
# mark some as excluded based on numbers above
|
||||||
for trash_sphere, fills in trash_fills.items():
|
for trash_sphere, fills in trash_fills.items():
|
||||||
for typ, counts in fills.items():
|
for typ, counts in fills.items():
|
||||||
count = counts[self.multiworld.difficulty[self.player].value]
|
count = counts[self.options.difficulty.value]
|
||||||
for location in self.multiworld.random.sample(spheres[trash_sphere][typ], count):
|
for location in self.random.sample(spheres[trash_sphere][typ], count):
|
||||||
assert location.name != "Energy Core #285", "Error in sphere generation"
|
assert location.name != "Energy Core #285", "Error in sphere generation"
|
||||||
location.progress_type = LocationProgressType.EXCLUDED
|
location.progress_type = LocationProgressType.EXCLUDED
|
||||||
|
|
||||||
def sphere1_blocked_items_rule(item):
|
def sphere1_blocked_items_rule(item: pyevermizer.Item) -> bool:
|
||||||
if isinstance(item, SoEItem):
|
if isinstance(item, SoEItem):
|
||||||
# disable certain items in sphere 1
|
# disable certain items in sphere 1
|
||||||
if item.name in {"Gauge", "Wheel"}:
|
if item.name in {"Gauge", "Wheel"}:
|
||||||
return False
|
return False
|
||||||
# and some more for non-easy, non-mystery
|
# 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"}:
|
if item.name in {"Laser Lance", "Atom Smasher", "Diamond Eye"}:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
@ -273,13 +266,13 @@ class SoEWorld(World):
|
||||||
add_item_rule(location, sphere1_blocked_items_rule)
|
add_item_rule(location, sphere1_blocked_items_rule)
|
||||||
|
|
||||||
# make some logically late(r) bosses priority locations to increase complexity
|
# make some logically late(r) bosses priority locations to increase complexity
|
||||||
if self.multiworld.difficulty[self.player] == Difficulty.option_mystery:
|
if self.options.difficulty == Difficulty.option_mystery:
|
||||||
late_count = self.multiworld.random.randint(0, 2)
|
late_count = self.random.randint(0, 2)
|
||||||
else:
|
else:
|
||||||
late_count = self.multiworld.difficulty[self.player].value
|
late_count = self.options.difficulty.value
|
||||||
late_bosses = ("Tiny", "Aquagoth", "Megataur", "Rimsala",
|
late_bosses = ("Tiny", "Aquagoth", "Megataur", "Rimsala",
|
||||||
"Mungola", "Lightning Storm", "Magmar", "Volcano Viper")
|
"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
|
# add locations to the world
|
||||||
for sphere in spheres.values():
|
for sphere in spheres.values():
|
||||||
|
@ -293,17 +286,17 @@ class SoEWorld(World):
|
||||||
menu.connect(ingame, "New Game")
|
menu.connect(ingame, "New Game")
|
||||||
self.multiworld.regions += [ingame]
|
self.multiworld.regions += [ingame]
|
||||||
|
|
||||||
def create_items(self):
|
def create_items(self) -> None:
|
||||||
# add regular items to the pool
|
# add regular items to the pool
|
||||||
exclusions: typing.List[str] = []
|
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
|
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)))
|
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
|
# remove one pair of wings that will be placed in generate_basic
|
||||||
items.remove(self.create_item("Wings"))
|
items.remove(self.create_item("Wings"))
|
||||||
|
|
||||||
def is_ingredient(item):
|
def is_ingredient(item: pyevermizer.Item) -> bool:
|
||||||
for ingredient in _ingredients:
|
for ingredient in _ingredients:
|
||||||
if _match_item_name(item, ingredient):
|
if _match_item_name(item, ingredient):
|
||||||
return True
|
return True
|
||||||
|
@ -311,84 +304,74 @@ class SoEWorld(World):
|
||||||
|
|
||||||
# add energy core fragments to the pool
|
# add energy core fragments to the pool
|
||||||
ingredients = [n for n, item in enumerate(items) if is_ingredient(item)]
|
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
|
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:
|
if len(ingredients) < 1:
|
||||||
break # out of ingredients to replace
|
break # out of ingredients to replace
|
||||||
r = self.multiworld.random.choice(ingredients)
|
r = self.random.choice(ingredients)
|
||||||
ingredients.remove(r)
|
ingredients.remove(r)
|
||||||
items[r] = self.create_item("Energy Core Fragment")
|
items[r] = self.create_item("Energy Core Fragment")
|
||||||
|
|
||||||
# add traps to the pool
|
# add traps to the pool
|
||||||
trap_count = self.multiworld.trap_count[self.player].value
|
trap_count = self.options.trap_count.value
|
||||||
trap_chances = {}
|
trap_names: typing.List[str] = []
|
||||||
trap_names = {}
|
trap_weights: typing.List[int] = []
|
||||||
if trap_count > 0:
|
if trap_count > 0:
|
||||||
for trap_type in self.trap_types:
|
for trap_option in self.options.trap_chances:
|
||||||
trap_option = getattr(self.multiworld, f'trap_chance_{trap_type}')[self.player]
|
trap_names.append(trap_option.item_name)
|
||||||
trap_chances[trap_type] = trap_option.value
|
trap_weights.append(trap_option.value)
|
||||||
trap_names[trap_type] = trap_option.item_name
|
if sum(trap_weights) == 0:
|
||||||
trap_chances_total = sum(trap_chances.values())
|
trap_weights = [1 for _ in trap_weights]
|
||||||
if trap_chances_total == 0:
|
|
||||||
for trap_type in trap_chances:
|
|
||||||
trap_chances[trap_type] = 1
|
|
||||||
trap_chances_total = len(trap_chances)
|
|
||||||
|
|
||||||
def create_trap() -> Item:
|
def create_trap() -> Item:
|
||||||
v = self.multiworld.random.randrange(trap_chances_total)
|
return self.create_item(self.random.choices(trap_names, trap_weights)[0])
|
||||||
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"
|
|
||||||
|
|
||||||
for _ in range(trap_count):
|
for _ in range(trap_count):
|
||||||
if len(ingredients) < 1:
|
if len(ingredients) < 1:
|
||||||
break # out of ingredients to replace
|
break # out of ingredients to replace
|
||||||
r = self.multiworld.random.choice(ingredients)
|
r = self.random.choice(ingredients)
|
||||||
ingredients.remove(r)
|
ingredients.remove(r)
|
||||||
items[r] = create_trap()
|
items[r] = create_trap()
|
||||||
|
|
||||||
self.multiworld.itempool += items
|
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)
|
self.multiworld.completion_condition[self.player] = lambda state: state.has('Victory', self.player)
|
||||||
# set Done from goal option once we have multiple goals
|
# set Done from goal option once we have multiple goals
|
||||||
set_rule(self.multiworld.get_location('Done', self.player),
|
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)
|
set_rule(self.multiworld.get_entrance('New Game', self.player), lambda state: True)
|
||||||
for loc in _locations:
|
for loc in _locations:
|
||||||
location = self.multiworld.get_location(loc.name, self.player)
|
location = self.multiworld.get_location(loc.name, self.player)
|
||||||
set_rule(location, self.make_rule(loc.requires))
|
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 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:
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
def make_item_type_limit_rule(self, item_type: int):
|
def generate_basic(self) -> None:
|
||||||
return lambda item: item.player != self.player or self.item_id_to_raw[item.code].type == item_type
|
|
||||||
|
|
||||||
def generate_basic(self):
|
|
||||||
# place Victory event
|
# place Victory event
|
||||||
self.multiworld.get_location('Done', self.player).place_locked_item(self.create_event('Victory'))
|
self.multiworld.get_location('Done', self.player).place_locked_item(self.create_event('Victory'))
|
||||||
# place wings in halls NE to avoid softlock
|
# 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')
|
wings_item = self.create_item('Wings')
|
||||||
self.multiworld.get_location(wings_location, self.player).place_locked_item(wings_item)
|
self.multiworld.get_location(wings_location, self.player).place_locked_item(wings_item)
|
||||||
# place energy core at vanilla location for vanilla mode
|
# 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')
|
energy_core = self.create_item('Energy Core')
|
||||||
self.multiworld.get_location('Energy Core #285', self.player).place_locked_item(energy_core)
|
self.multiworld.get_location('Energy Core #285', self.player).place_locked_item(energy_core)
|
||||||
# generate stuff for later
|
# 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)
|
player_name = self.multiworld.get_player_name(self.player)
|
||||||
self.connect_name = player_name[:32]
|
self.connect_name = player_name[:32]
|
||||||
while len(self.connect_name.encode('utf-8')) > 32:
|
while len(self.connect_name.encode('utf-8')) > 32:
|
||||||
|
@ -397,24 +380,21 @@ class SoEWorld(World):
|
||||||
placement_file = ""
|
placement_file = ""
|
||||||
out_file = ""
|
out_file = ""
|
||||||
try:
|
try:
|
||||||
money = self.multiworld.money_modifier[self.player].value
|
money = self.options.money_modifier.value
|
||||||
exp = self.multiworld.exp_modifier[self.player].value
|
exp = self.options.exp_modifier.value
|
||||||
switches: typing.List[str] = []
|
switches: typing.List[str] = []
|
||||||
if self.multiworld.death_link[self.player].value:
|
if self.options.death_link.value:
|
||||||
switches.append("--death-link")
|
switches.append("--death-link")
|
||||||
if self.energy_core == EnergyCore.option_fragments:
|
if self.options.energy_core == EnergyCore.option_fragments:
|
||||||
switches.extend(('--available-fragments', str(self.available_fragments),
|
switches.extend(('--available-fragments', str(self.options.available_fragments.value),
|
||||||
'--required-fragments', str(self.required_fragments)))
|
'--required-fragments', str(self.options.required_fragments.value)))
|
||||||
rom_file = get_base_rom_path()
|
rom_file = get_base_rom_path()
|
||||||
out_base = output_path(output_directory, self.multiworld.get_out_file_name_base(self.player))
|
out_base = output_path(output_directory, self.multiworld.get_out_file_name_base(self.player))
|
||||||
out_file = out_base + '.sfc'
|
out_file = out_base + '.sfc'
|
||||||
placement_file = out_base + '.txt'
|
placement_file = out_base + '.txt'
|
||||||
patch_file = out_base + '.apsoe'
|
patch_file = out_base + '.apsoe'
|
||||||
flags = 'l' # spoiler log
|
flags = 'l' # spoiler log
|
||||||
for option_name in self.option_definitions:
|
flags += self.options.flags
|
||||||
option = getattr(self.multiworld, option_name)[self.player]
|
|
||||||
if hasattr(option, 'to_flag'):
|
|
||||||
flags += option.to_flag()
|
|
||||||
|
|
||||||
with open(placement_file, "wb") as f: # generate placement file
|
with open(placement_file, "wb") as f: # generate placement file
|
||||||
for location in self.multiworld.get_locations(self.player):
|
for location in self.multiworld.get_locations(self.player):
|
||||||
|
@ -448,7 +428,7 @@ class SoEWorld(World):
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
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.
|
# wait for self.connect_name to be available.
|
||||||
self.connect_name_available_event.wait()
|
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
|
# 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
|
multidata["connect_names"][self.connect_name] = payload
|
||||||
|
|
||||||
def get_filler_item_name(self) -> str:
|
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):
|
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
|
# typing boilerplate
|
||||||
class FlagsProtocol(typing.Protocol):
|
class FlagsProtocol(Protocol):
|
||||||
value: int
|
value: int
|
||||||
default: int
|
default: int
|
||||||
flags: typing.List[str]
|
flags: List[str]
|
||||||
|
|
||||||
|
|
||||||
class FlagProtocol(typing.Protocol):
|
class FlagProtocol(Protocol):
|
||||||
value: int
|
value: int
|
||||||
default: int
|
default: int
|
||||||
flag: str
|
flag: str
|
||||||
|
@ -18,7 +20,7 @@ class FlagProtocol(typing.Protocol):
|
||||||
|
|
||||||
# meta options
|
# meta options
|
||||||
class EvermizerFlags:
|
class EvermizerFlags:
|
||||||
flags: typing.List[str]
|
flags: List[str]
|
||||||
|
|
||||||
def to_flag(self: FlagsProtocol) -> str:
|
def to_flag(self: FlagsProtocol) -> str:
|
||||||
return self.flags[self.value]
|
return self.flags[self.value]
|
||||||
|
@ -200,13 +202,13 @@ class TrapCount(Range):
|
||||||
|
|
||||||
# more meta options
|
# more meta options
|
||||||
class ItemChanceMeta(AssembleOptions):
|
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:
|
if 'item_name' in attrs:
|
||||||
attrs["display_name"] = f"{attrs['item_name']} Chance"
|
attrs["display_name"] = f"{attrs['item_name']} Chance"
|
||||||
attrs["range_start"] = 0
|
attrs["range_start"] = 0
|
||||||
attrs["range_end"] = 100
|
attrs["range_end"] = 100
|
||||||
|
cls = super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
|
||||||
return super(ItemChanceMeta, mcs).__new__(mcs, name, bases, attrs)
|
return cast(ItemChanceMeta, cls)
|
||||||
|
|
||||||
|
|
||||||
class TrapChance(Range, metaclass=ItemChanceMeta):
|
class TrapChance(Range, metaclass=ItemChanceMeta):
|
||||||
|
@ -247,33 +249,50 @@ class SoEProgressionBalancing(ProgressionBalancing):
|
||||||
special_range_names = {**ProgressionBalancing.special_range_names, "normal": default}
|
special_range_names = {**ProgressionBalancing.special_range_names, "normal": default}
|
||||||
|
|
||||||
|
|
||||||
soe_options: typing.Dict[str, AssembleOptions] = {
|
# noinspection SpellCheckingInspection
|
||||||
"difficulty": Difficulty,
|
@dataclass
|
||||||
"energy_core": EnergyCore,
|
class SoEOptions(PerGameCommonOptions):
|
||||||
"required_fragments": RequiredFragments,
|
difficulty: Difficulty
|
||||||
"available_fragments": AvailableFragments,
|
energy_core: EnergyCore
|
||||||
"money_modifier": MoneyModifier,
|
required_fragments: RequiredFragments
|
||||||
"exp_modifier": ExpModifier,
|
available_fragments: AvailableFragments
|
||||||
"sequence_breaks": SequenceBreaks,
|
money_modifier: MoneyModifier
|
||||||
"out_of_bounds": OutOfBounds,
|
exp_modifier: ExpModifier
|
||||||
"fix_cheats": FixCheats,
|
sequence_breaks: SequenceBreaks
|
||||||
"fix_infinite_ammo": FixInfiniteAmmo,
|
out_of_bounds: OutOfBounds
|
||||||
"fix_atlas_glitch": FixAtlasGlitch,
|
fix_cheats: FixCheats
|
||||||
"fix_wings_glitch": FixWingsGlitch,
|
fix_infinite_ammo: FixInfiniteAmmo
|
||||||
"shorter_dialogs": ShorterDialogs,
|
fix_atlas_glitch: FixAtlasGlitch
|
||||||
"short_boss_rush": ShortBossRush,
|
fix_wings_glitch: FixWingsGlitch
|
||||||
"ingredienizer": Ingredienizer,
|
shorter_dialogs: ShorterDialogs
|
||||||
"sniffamizer": Sniffamizer,
|
short_boss_rush: ShortBossRush
|
||||||
"callbeadamizer": Callbeadamizer,
|
ingredienizer: Ingredienizer
|
||||||
"musicmizer": Musicmizer,
|
sniffamizer: Sniffamizer
|
||||||
"doggomizer": Doggomizer,
|
callbeadamizer: Callbeadamizer
|
||||||
"turdo_mode": TurdoMode,
|
musicmizer: Musicmizer
|
||||||
"death_link": DeathLink,
|
doggomizer: Doggomizer
|
||||||
"trap_count": TrapCount,
|
turdo_mode: TurdoMode
|
||||||
"trap_chance_quake": TrapChanceQuake,
|
death_link: DeathLink
|
||||||
"trap_chance_poison": TrapChancePoison,
|
trap_count: TrapCount
|
||||||
"trap_chance_confound": TrapChanceConfound,
|
trap_chance_quake: TrapChanceQuake
|
||||||
"trap_chance_hud": TrapChanceHUD,
|
trap_chance_poison: TrapChancePoison
|
||||||
"trap_chance_ohko": TrapChanceOHKO,
|
trap_chance_confound: TrapChanceConfound
|
||||||
"progression_balancing": SoEProgressionBalancing,
|
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
|
import os
|
||||||
from typing import Optional
|
from typing import BinaryIO, Optional
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from worlds.Files import APDeltaPatch
|
from worlds.Files import APDeltaPatch
|
||||||
|
@ -30,7 +30,7 @@ def get_base_rom_path(file_name: Optional[str] = None) -> str:
|
||||||
return file_name
|
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"""
|
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||||
data = stream.read()
|
data = stream.read()
|
||||||
if strip_header and len(data) % 0x400 == 0x200:
|
if strip_header and len(data) % 0x400 == 0x200:
|
||||||
|
@ -40,5 +40,5 @@ def read_rom(stream, strip_header=True) -> bytes:
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import sys
|
import sys
|
||||||
print('Please use ../../Patch.py', file=sys.stderr)
|
print('Please use ../../patch.py', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
|
@ -1,4 +1,4 @@
|
||||||
from test.TestBase import WorldTestBase
|
from test.bases import WorldTestBase
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,3 +18,14 @@ class SoETestBase(WorldTestBase):
|
||||||
for location in unreachable:
|
for location in unreachable:
|
||||||
self.assertFalse(self.can_reach_location(location),
|
self.assertFalse(self.can_reach_location(location),
|
||||||
f"{location} is reachable but shouldn't be")
|
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]]):
|
def _resolveGourds(gourds: typing.Dict[str, typing.Iterable[int]]):
|
||||||
return [f"{name} #{number}" for name, numbers in gourds.items() for number in numbers]
|
return [f"{name} #{number}" for name, numbers in gourds.items() for number in numbers]
|
||||||
|
|
||||||
def testBronzeAxe(self):
|
def test_bronze_axe(self):
|
||||||
gourds = {
|
gourds = {
|
||||||
"Pyramid bottom": (118, 121, 122, 123, 124, 125),
|
"Pyramid bottom": (118, 121, 122, 123, 124, 125),
|
||||||
"Pyramid top": (140,)
|
"Pyramid top": (140,)
|
||||||
|
@ -16,7 +16,7 @@ class AccessTest(SoETestBase):
|
||||||
items = [["Bronze Axe"]]
|
items = [["Bronze Axe"]]
|
||||||
self.assertAccessDependency(locations, items)
|
self.assertAccessDependency(locations, items)
|
||||||
|
|
||||||
def testBronzeSpearPlus(self):
|
def test_bronze_spear_plus(self):
|
||||||
locations = ["Megataur"]
|
locations = ["Megataur"]
|
||||||
items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]]
|
items = [["Bronze Spear"], ["Lance (Weapon)"], ["Laser Lance"]]
|
||||||
self.assertAccessDependency(locations, items)
|
self.assertAccessDependency(locations, items)
|
||||||
|
|
|
@ -8,7 +8,7 @@ class TestFragmentGoal(SoETestBase):
|
||||||
"required_fragments": 20,
|
"required_fragments": 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
def testFragments(self):
|
def test_fragments(self):
|
||||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||||
self.assertBeatable(False) # 0 fragments
|
self.assertBeatable(False) # 0 fragments
|
||||||
fragments = self.get_items_by_name("Energy Core Fragment")
|
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.assertEqual(self.count("Energy Core Fragment"), 21)
|
||||||
self.assertBeatable(True)
|
self.assertBeatable(True)
|
||||||
|
|
||||||
def testNoWeapon(self):
|
def test_no_weapon(self):
|
||||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core Fragment"])
|
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core Fragment"])
|
||||||
self.assertBeatable(False)
|
self.assertBeatable(False)
|
||||||
|
|
||||||
def testNoRocket(self):
|
def test_no_rocket(self):
|
||||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core Fragment"])
|
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core Fragment"])
|
||||||
self.assertBeatable(False)
|
self.assertBeatable(False)
|
||||||
|
|
||||||
|
@ -38,16 +38,16 @@ class TestShuffleGoal(SoETestBase):
|
||||||
"energy_core": "shuffle",
|
"energy_core": "shuffle",
|
||||||
}
|
}
|
||||||
|
|
||||||
def testCore(self):
|
def test_core(self):
|
||||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"])
|
||||||
self.assertBeatable(False)
|
self.assertBeatable(False)
|
||||||
self.collect_by_name(["Energy Core"])
|
self.collect_by_name(["Energy Core"])
|
||||||
self.assertBeatable(True)
|
self.assertBeatable(True)
|
||||||
|
|
||||||
def testNoWeapon(self):
|
def test_no_weapon(self):
|
||||||
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core"])
|
self.collect_by_name(["Diamond Eye", "Wheel", "Gauge", "Energy Core"])
|
||||||
self.assertBeatable(False)
|
self.assertBeatable(False)
|
||||||
|
|
||||||
def testNoRocket(self):
|
def test_no_rocket(self):
|
||||||
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core"])
|
self.collect_by_name(["Gladiator Sword", "Diamond Eye", "Wheel", "Energy Core"])
|
||||||
self.assertBeatable(False)
|
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."""
|
"""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"}
|
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"
|
in_logic = self.options["out_of_bounds"] == "logic"
|
||||||
|
|
||||||
# some locations that just need a weapon + OoB
|
# some locations that just need a weapon + OoB
|
||||||
|
@ -37,7 +37,7 @@ class OoBTest(SoETestBase):
|
||||||
self.collect_by_name("Diamond Eye")
|
self.collect_by_name("Diamond Eye")
|
||||||
self.assertLocationReachability(reachable=de_reachable, unreachable=de_unreachable, satisfied=in_logic)
|
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
|
# still need Energy Core with OoB if sequence breaks are not in logic
|
||||||
for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]:
|
for item in ["Gladiator Sword", "Diamond Eye", "Wheel", "Gauge"]:
|
||||||
self.collect_by_name(item)
|
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."""
|
"""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"}
|
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"
|
in_logic = self.options["sequence_breaks"] == "logic"
|
||||||
|
|
||||||
# some locations that just need any weapon + sequence break
|
# 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.collect_by_name("Bronze Spear") # Escape now just needs either Megataur or Rimsala dead
|
||||||
self.assertEqual(self.can_reach_location("Escape"), in_logic)
|
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"
|
in_logic = self.options["sequence_breaks"] == "logic"
|
||||||
|
|
||||||
# don't need Energy Core with sequence breaks in 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