mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 20:21:32 -06:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user