mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

Currently there can be locations that are marked as excluded, but don't have rules to enforce it, while fill has special handling for excluded locations already. This change removes special rules, and adds a generic rule instead.
465 lines
21 KiB
Python
465 lines
21 KiB
Python
import itertools
|
|
import os
|
|
import os.path
|
|
import threading
|
|
import typing
|
|
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, RegionType, Tutorial
|
|
from Utils import output_path
|
|
|
|
try:
|
|
import pyevermizer # from package
|
|
except ImportError:
|
|
import traceback
|
|
traceback.print_exc()
|
|
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
|
|
|
|
"""
|
|
In evermizer:
|
|
|
|
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
|
|
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
|
|
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
|
|
e.g. 2DEs+Wheel+Gauge = Rocket
|
|
|
|
Rules and Locations live on the same logic tree returned by pyevermizer.get_logic()
|
|
|
|
TODO: for balancing we may want to generate Regions (with Entrances) for some
|
|
common rules, place the locations in those Regions and shorten the rules.
|
|
|
|
|
|
Item grouping currently supports
|
|
* Any <ingredient name> - "Any Water" matches all Water drops
|
|
* Any <healing item name> - "Any Petal" matches all Petal drops
|
|
* Any Moniez - Matches the talon/jewel/gold coin/credit drops from chests (not market, fountain or Mungola)
|
|
* Ingredients - Matches all ingredient drops
|
|
* Alchemy - Matches all alchemy formulas
|
|
* Weapons - Matches all weapons but Bazooka, Bone Crusher, Neutron Blade
|
|
* Traps - Matches all traps
|
|
"""
|
|
|
|
_id_base = 64000
|
|
_id_offset: typing.Dict[int, int] = {
|
|
pyevermizer.CHECK_ALCHEMY: _id_base + 0, # alchemy 64000..64049
|
|
pyevermizer.CHECK_BOSS: _id_base + 50, # bosses 64050..6499
|
|
pyevermizer.CHECK_GOURD: _id_base + 100, # gourds 64100..64399
|
|
pyevermizer.CHECK_NPC: _id_base + 400, # npc 64400..64499
|
|
# TODO: sniff 64500..64799
|
|
pyevermizer.CHECK_EXTRA: _id_base + 800, # extra items 64800..64899
|
|
pyevermizer.CHECK_TRAP: _id_base + 900, # trap 64900..64999
|
|
}
|
|
|
|
# cache native evermizer items and locations
|
|
_items = pyevermizer.get_items()
|
|
_traps = pyevermizer.get_traps()
|
|
_extras = pyevermizer.get_extra_items() # items that are not placed by default
|
|
_locations = pyevermizer.get_locations()
|
|
# fix up texts for AP
|
|
for _loc in _locations:
|
|
if _loc.type == pyevermizer.CHECK_GOURD:
|
|
_loc.name = f'{_loc.name} #{_loc.index}'
|
|
# item helpers
|
|
_ingredients = (
|
|
'Wax', 'Water', 'Vinegar', 'Root', 'Oil', 'Mushroom', 'Mud Pepper', 'Meteorite', 'Limestone', 'Iron',
|
|
'Gunpowder', 'Grease', 'Feather', 'Ethanol', 'Dry Ice', 'Crystal', 'Clay', 'Brimstone', 'Bone', 'Atlas Amulet',
|
|
'Ash', 'Acorn'
|
|
)
|
|
_other_items = (
|
|
'Call bead', 'Petal', 'Biscuit', 'Pixie Dust', 'Nectar', 'Honey', 'Moniez'
|
|
)
|
|
|
|
|
|
def _match_item_name(item, substr: str) -> bool:
|
|
sub = item.name.split(' ', 1)[1] if item.name[0].isdigit() else item.name
|
|
return sub == substr or sub == substr+'s'
|
|
|
|
|
|
def _get_location_mapping() -> typing.Tuple[typing.Dict[str, int], typing.Dict[int, pyevermizer.Location]]:
|
|
name_to_id = {}
|
|
id_to_raw = {}
|
|
for loc in _locations:
|
|
ap_id = _id_offset[loc.type] + loc.index
|
|
id_to_raw[ap_id] = loc
|
|
name_to_id[loc.name] = ap_id
|
|
name_to_id['Done'] = None
|
|
return name_to_id, id_to_raw
|
|
|
|
|
|
def _get_item_mapping() -> typing.Tuple[typing.Dict[str, int], typing.Dict[int, pyevermizer.Item]]:
|
|
name_to_id = {}
|
|
id_to_raw = {}
|
|
for item in itertools.chain(_items, _extras, _traps):
|
|
if item.name in name_to_id:
|
|
continue
|
|
ap_id = _id_offset[item.type] + item.index
|
|
id_to_raw[ap_id] = item
|
|
name_to_id[item.name] = ap_id
|
|
name_to_id['Victory'] = None
|
|
return name_to_id, id_to_raw
|
|
|
|
|
|
def _get_item_grouping() -> typing.Dict[str, typing.Set[str]]:
|
|
groups = {}
|
|
ingredients_group = set()
|
|
for ingredient in _ingredients:
|
|
group = set(item.name for item in _items if _match_item_name(item, ingredient))
|
|
groups[f'Any {ingredient}'] = group
|
|
ingredients_group |= group
|
|
groups['Ingredients'] = ingredients_group
|
|
for other in _other_items:
|
|
groups[f'Any {other}'] = set(item.name for item in _items if _match_item_name(item, other))
|
|
groups['Alchemy'] = set(item.name for item in _items if item.type == pyevermizer.CHECK_ALCHEMY)
|
|
groups['Weapons'] = {'Spider Claw', 'Horn Spear', 'Gladiator Sword', 'Bronze Axe', 'Bronze Spear', 'Crusader Sword',
|
|
'Lance (Weapon)', 'Knight Basher', 'Atom Smasher', 'Laser Lance'}
|
|
groups['Traps'] = {trap.name for trap in _traps}
|
|
return groups
|
|
|
|
|
|
class SoEWebWorld(WebWorld):
|
|
theme = 'jungle'
|
|
tutorials = [Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related"
|
|
" software.",
|
|
"English",
|
|
"multiworld_en.md",
|
|
"multiworld/en",
|
|
["Black Sliver"]
|
|
)]
|
|
|
|
|
|
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
|
|
topology_present = False
|
|
remote_items = False
|
|
data_version = 4
|
|
web = SoEWebWorld()
|
|
required_client_version = (0, 3, 5)
|
|
|
|
item_name_to_id, item_id_to_raw = _get_item_mapping()
|
|
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_')]
|
|
|
|
evermizer_seed: int
|
|
connect_name: str
|
|
energy_core: 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):
|
|
self.connect_name_available_event = threading.Event()
|
|
super(SoEWorld, self).__init__(*args, **kwargs)
|
|
|
|
def generate_early(self) -> None:
|
|
# store option values that change logic
|
|
self.energy_core = self.multiworld.energy_core[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
|
|
|
|
def create_event(self, event: str) -> Item:
|
|
return SoEItem(event, ItemClassification.progression, None, self.player)
|
|
|
|
def create_item(self, item: typing.Union[pyevermizer.Item, str]) -> Item:
|
|
if isinstance(item, str):
|
|
item = self.item_id_to_raw[self.item_name_to_id[item]]
|
|
if item.type == pyevermizer.CHECK_TRAP:
|
|
classification = ItemClassification.trap
|
|
elif item.progression:
|
|
classification = ItemClassification.progression
|
|
elif item.useful:
|
|
classification = ItemClassification.useful
|
|
else:
|
|
classification = ItemClassification.filler
|
|
|
|
return SoEItem(item.name, classification, self.item_name_to_id[item.name], self.player)
|
|
|
|
@classmethod
|
|
def stage_assert_generate(cls, world):
|
|
rom_file = get_base_rom_path()
|
|
if not os.path.exists(rom_file):
|
|
raise FileNotFoundError(rom_file)
|
|
|
|
def create_regions(self):
|
|
# exclude 'hidden' on easy
|
|
max_difficulty = 1 if self.multiworld.difficulty[self.player] == Difficulty.option_easy else 256
|
|
|
|
# TODO: generate *some* regions from locations' requirements?
|
|
r = Region('Menu', RegionType.Generic, 'Menu', self.player, self.multiworld)
|
|
r.exits = [Entrance(self.player, 'New Game', r)]
|
|
self.multiworld.regions += [r]
|
|
|
|
def get_sphere_index(evermizer_loc):
|
|
"""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
|
|
return min(2, len(evermizer_loc.requires))
|
|
|
|
# group locations into spheres (1, 2, 3+ at index 0, 1, 2)
|
|
spheres: typing.Dict[int, typing.Dict[int, typing.List[SoELocation]]] = {}
|
|
for loc in _locations:
|
|
spheres.setdefault(get_sphere_index(loc), {}).setdefault(loc.type, []).append(
|
|
SoELocation(self.player, loc.name, self.location_name_to_id[loc.name], r,
|
|
loc.difficulty > max_difficulty))
|
|
|
|
# location balancing data
|
|
trash_fills: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int, int]]] = {
|
|
0: {pyevermizer.CHECK_GOURD: (20, 40, 40, 40)}, # remove up to 40 gourds from sphere 1
|
|
1: {pyevermizer.CHECK_GOURD: (70, 90, 90, 90)}, # remove up to 90 gourds from sphere 2
|
|
}
|
|
|
|
# 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):
|
|
assert location.name != "Energy Core #285", "Error in sphere generation"
|
|
location.progress_type = LocationProgressType.EXCLUDED
|
|
|
|
def sphere1_blocked_items_rule(item):
|
|
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 item.name in {"Laser Lance", "Atom Smasher", "Diamond Eye"}:
|
|
return False
|
|
return True
|
|
|
|
for locations in spheres[0].values():
|
|
for location in locations:
|
|
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)
|
|
else:
|
|
late_count = self.multiworld.difficulty[self.player].value
|
|
late_bosses = ("Tiny", "Aquagoth", "Megataur", "Rimsala",
|
|
"Mungola", "Lightning Storm", "Magmar", "Volcano Viper")
|
|
late_locations = self.multiworld.random.sample(late_bosses, late_count)
|
|
|
|
# add locations to the world
|
|
r = Region('Ingame', RegionType.Generic, 'Ingame', self.player, self.multiworld)
|
|
for sphere in spheres.values():
|
|
for locations in sphere.values():
|
|
for location in locations:
|
|
r.locations.append(location)
|
|
if location.name in late_locations:
|
|
location.progress_type = LocationProgressType.PRIORITY
|
|
|
|
r.locations.append(SoELocation(self.player, 'Done', None, r))
|
|
self.multiworld.regions += [r]
|
|
|
|
self.multiworld.get_entrance('New Game', self.player).connect(self.multiworld.get_region('Ingame', self.player))
|
|
|
|
def create_items(self):
|
|
# add regular items to the pool
|
|
exclusions: typing.List[str] = []
|
|
if self.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):
|
|
for ingredient in _ingredients:
|
|
if _match_item_name(item, ingredient):
|
|
return True
|
|
return False
|
|
|
|
# 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:
|
|
items.append(self.create_item("Energy Core Fragment")) # replaces the vanilla energy core
|
|
for _ in range(self.available_fragments - 1):
|
|
if len(ingredients) < 1:
|
|
break # out of ingredients to replace
|
|
r = self.multiworld.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 = {}
|
|
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)
|
|
|
|
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"
|
|
|
|
for _ in range(trap_count):
|
|
if len(ingredients) < 1:
|
|
break # out of ingredients to replace
|
|
r = self.multiworld.random.choice(ingredients)
|
|
ingredients.remove(r)
|
|
items[r] = create_trap()
|
|
|
|
self.multiworld.itempool += items
|
|
|
|
def set_rules(self):
|
|
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))
|
|
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:
|
|
for count, progress in requires:
|
|
if not state.soe_has(progress, self.multiworld, self.player, 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):
|
|
# 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_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:
|
|
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?
|
|
|
|
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:
|
|
self.connect_name = self.connect_name[:-1]
|
|
self.connect_name_available_event.set()
|
|
placement_file = ""
|
|
out_file = ""
|
|
try:
|
|
money = self.multiworld.money_modifier[self.player].value
|
|
exp = self.multiworld.exp_modifier[self.player].value
|
|
switches: typing.List[str] = []
|
|
if self.multiworld.death_link[self.player].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)))
|
|
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()
|
|
|
|
with open(placement_file, "wb") as f: # generate placement file
|
|
for location in filter(lambda l: l.player == self.player, self.multiworld.get_locations()):
|
|
item = location.item
|
|
assert item is not None, "Can't handle unfilled location"
|
|
if item.code is None or location.address is None:
|
|
continue # skip events
|
|
loc = self.location_id_to_raw[location.address]
|
|
if item.player != self.player:
|
|
line = f'{loc.type},{loc.index}:{pyevermizer.CHECK_NONE},{item.code},{item.player}\n'
|
|
else:
|
|
soe_item = self.item_id_to_raw[item.code]
|
|
line = f'{loc.type},{loc.index}:{soe_item.type},{soe_item.index}\n'
|
|
f.write(line.encode('utf-8'))
|
|
|
|
if not os.path.exists(rom_file):
|
|
raise FileNotFoundError(rom_file)
|
|
if (pyevermizer.main(rom_file, out_file, placement_file, self.multiworld.seed_name, self.connect_name,
|
|
self.evermizer_seed, flags, money, exp, switches)):
|
|
raise RuntimeError()
|
|
patch = SoEDeltaPatch(patch_file, player=self.player,
|
|
player_name=player_name, patched_path=out_file)
|
|
patch.write()
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
try:
|
|
os.unlink(placement_file)
|
|
os.unlink(out_file)
|
|
os.unlink(out_file[:-4] + '_SPOILER.log')
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def modify_multidata(self, multidata: dict):
|
|
# 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
|
|
if self.connect_name and self.connect_name != self.multiworld.player_name[self.player]:
|
|
payload = multidata["connect_names"][self.multiworld.player_name[self.player]]
|
|
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"]))
|
|
|
|
|
|
class SoEItem(Item):
|
|
game: str = "Secret of Evermore"
|
|
__slots__ = () # disable __dict__
|
|
|
|
|
|
class SoELocation(Location):
|
|
game: str = "Secret of Evermore"
|
|
__slots__ = () # disables __dict__ once Location has __slots__
|
|
|
|
def __init__(self, player: int, name: str, address: typing.Optional[int], parent: Region, exclude: bool = False):
|
|
super().__init__(player, name, address, parent)
|
|
# unconditional assignments favor a split dict, saving memory
|
|
self.progress_type = LocationProgressType.EXCLUDED if exclude else LocationProgressType.DEFAULT
|
|
self.event = not address
|