forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
421 lines
16 KiB
Python
421 lines
16 KiB
Python
"""
|
|
Archipelago World definition for Fire Emblem: Sacred Stones
|
|
"""
|
|
|
|
from typing import ClassVar, Optional, Callable, Set, Tuple, Any
|
|
import os
|
|
import pkgutil
|
|
|
|
# import logging
|
|
|
|
from Options import OptionError
|
|
from worlds.AutoWorld import World, WebWorld
|
|
from BaseClasses import (
|
|
Region,
|
|
ItemClassification,
|
|
CollectionState,
|
|
Tutorial,
|
|
)
|
|
import settings
|
|
|
|
from .client import FE8Client
|
|
from .options import FE8Options
|
|
from .constants import (
|
|
FE8_NAME,
|
|
FE8_ID_PREFIX,
|
|
NUM_LEVELCAPS,
|
|
WEAPON_TYPES,
|
|
NUM_WEAPON_LEVELS,
|
|
HOLY_WEAPONS,
|
|
FILLER_ITEMS,
|
|
)
|
|
from .locations import FE8Location
|
|
from .items import FE8Item
|
|
from .connector_config import locations, items
|
|
|
|
from .rom import FE8ProcedurePatch, write_tokens
|
|
|
|
# We need to import FE8Client to register it properly, so we use it to disable
|
|
# the unused import warning
|
|
_ = FE8Client
|
|
|
|
|
|
class FE8WebWorld(WebWorld):
|
|
"""
|
|
Webhost info for FE8
|
|
"""
|
|
|
|
theme = "stone"
|
|
setup_en = Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to playing FE8 with Archipelago",
|
|
"English",
|
|
"setup_en.md",
|
|
"setup/en",
|
|
["CT075"],
|
|
)
|
|
|
|
tutorials = []
|
|
|
|
|
|
class FE8Settings(settings.Group):
|
|
class FE8RomFile(settings.UserFilePath):
|
|
"""File name of your Fire Emblem: The Sacred Stones (U) ROM"""
|
|
|
|
description = "FE8 ROM file"
|
|
copy_to = "Fire Emblem The Sacred Stones (U).gba"
|
|
md5s = [FE8ProcedurePatch.hash]
|
|
|
|
rom_file: FE8RomFile = FE8RomFile(FE8RomFile.copy_to)
|
|
|
|
|
|
class FE8World(World):
|
|
"""
|
|
Fire Emblem: The Sacred Stones is a tactical role-playing game developed by
|
|
Intelligent Systems, and published by Nintendo for the Game Boy Advance
|
|
handheld video game console in 2004 for Japan and 2005 in the West. It is
|
|
the eighth entry in the Fire Emblem series, the second to be released
|
|
outside Japan, and the third and final title to be developed for the Game
|
|
Boy Advance after The Binding Blade and its prequel Fire Emblem.
|
|
|
|
Build an army. Trust no one.
|
|
"""
|
|
|
|
game = FE8_NAME
|
|
base_id = FE8_ID_PREFIX
|
|
options_dataclass = FE8Options
|
|
settings_key = "fe8_settings"
|
|
settings: ClassVar[FE8Settings]
|
|
topology_present = False
|
|
web = FE8WebWorld()
|
|
progression_holy_weapons: Set[str] = set()
|
|
options: FE8Options
|
|
|
|
# TODO: populate for real
|
|
item_name_to_id = {name: id + FE8_ID_PREFIX for name, id in items}
|
|
location_name_to_id = {name: id + FE8_ID_PREFIX for name, id in locations}
|
|
item_name_groups = {"holy weapons": set(HOLY_WEAPONS.keys())}
|
|
|
|
def total_locations(self) -> int:
|
|
tower_checks_enabled = self.options.tower_checks_enabled()
|
|
ruins_checks_enabled = self.options.ruins_checks_enabled()
|
|
|
|
def is_included(loc: Tuple[str, int]):
|
|
name = loc[0]
|
|
if "Valni" in name and not tower_checks_enabled:
|
|
return False
|
|
if "Lagdou" in name and not ruins_checks_enabled:
|
|
return False
|
|
return True
|
|
|
|
return len([loc for loc in locations if is_included(loc)])
|
|
|
|
def create_item_with_classification(
|
|
self, item: str, cls: ItemClassification
|
|
) -> FE8Item:
|
|
return FE8Item(
|
|
item,
|
|
cls,
|
|
# CR cam: the `FE8Item` constructor also adds `FE8_ID_PREFIX`, so
|
|
# we need to subtract it here, which is awful.
|
|
self.item_name_to_id[item] - FE8_ID_PREFIX,
|
|
self.player,
|
|
)
|
|
|
|
def create_item(self, item: str) -> FE8Item:
|
|
return self.create_item_with_classification(
|
|
item,
|
|
# specific progression items are set during `create_items`, so we
|
|
# can safely assume that they're filler if created here.
|
|
ItemClassification.filler,
|
|
)
|
|
|
|
def create_items(self) -> None:
|
|
smooth_level_caps = self.options.smooth_level_caps
|
|
min_endgame_level_cap = int(self.options.min_endgame_level_cap)
|
|
exclude_latona = self.options.exclude_latona
|
|
required_holy_weapons = self.options.required_holy_weapons
|
|
|
|
smooth_levelcap_max = 25 if smooth_level_caps else 10
|
|
|
|
needed_level_uncaps = (
|
|
max(min_endgame_level_cap, smooth_levelcap_max) - 10
|
|
) // 5
|
|
|
|
progression_items: list[FE8Item] = []
|
|
other_items: list[FE8Item] = []
|
|
|
|
def register(name: str, cls: ItemClassification):
|
|
(
|
|
progression_items
|
|
if cls == ItemClassification.progression
|
|
else other_items
|
|
).append(self.create_item_with_classification(name, cls))
|
|
|
|
for i in range(NUM_LEVELCAPS):
|
|
register(
|
|
"Progressive Level Cap",
|
|
(
|
|
ItemClassification.progression
|
|
if i < needed_level_uncaps
|
|
else ItemClassification.useful
|
|
),
|
|
)
|
|
|
|
holy_weapon_pool = set(HOLY_WEAPONS.keys())
|
|
|
|
if exclude_latona:
|
|
holy_weapon_pool.remove("Latona")
|
|
|
|
if int(required_holy_weapons) > len(holy_weapon_pool):
|
|
raise OptionError("too many required holy weapons ({int(required_holy_weapons)})")
|
|
|
|
progression_holy_weapons = self.random.sample(
|
|
list(holy_weapon_pool), k=int(required_holy_weapons)
|
|
)
|
|
progression_weapon_types = set(HOLY_WEAPONS[w] for w in progression_holy_weapons)
|
|
|
|
self.progression_holy_weapons = set(progression_holy_weapons)
|
|
|
|
for wtype in WEAPON_TYPES:
|
|
for _ in range(NUM_WEAPON_LEVELS):
|
|
register(
|
|
"Progressive Weapon Level ({})".format(wtype),
|
|
(
|
|
ItemClassification.progression
|
|
if wtype in progression_weapon_types
|
|
else ItemClassification.useful
|
|
),
|
|
)
|
|
|
|
# We shuffle here to ensure that level caps and weapon levels come before
|
|
# holy weapons in `other_weapons`.
|
|
self.random.shuffle(other_items)
|
|
|
|
holy_weapons = [name for name in HOLY_WEAPONS.keys()]
|
|
self.random.shuffle(holy_weapons)
|
|
|
|
for hw in holy_weapons:
|
|
register(
|
|
hw,
|
|
(
|
|
ItemClassification.progression
|
|
if hw in progression_holy_weapons
|
|
else ItemClassification.useful
|
|
),
|
|
)
|
|
|
|
total_locations = self.total_locations()
|
|
|
|
if len(progression_items) > total_locations:
|
|
raise OptionError(
|
|
"Could not place all requested weapon levels and level uncaps. "
|
|
"Reduce the number of required Holy Weapons or disable smooth level caps."
|
|
)
|
|
|
|
for item in progression_items:
|
|
self.multiworld.itempool.append(item)
|
|
|
|
for _ in range(len(progression_items), total_locations):
|
|
if other_items:
|
|
self.multiworld.itempool.append(other_items.pop())
|
|
else:
|
|
self.multiworld.itempool.append(
|
|
self.create_item(self.random.choice(FILLER_ITEMS))
|
|
)
|
|
|
|
def add_location_to_region(self, name: str, addr: Optional[int], region: Region):
|
|
if addr is None:
|
|
# CR cam: we do the subtract here because `FE8Location` adds it
|
|
# back, which is just awful.
|
|
address = self.location_name_to_id[name] - FE8_ID_PREFIX
|
|
else:
|
|
address = addr
|
|
region.locations.append(FE8Location(self.player, name, address, region))
|
|
|
|
def create_regions(self) -> None:
|
|
smooth_level_caps = self.options.smooth_level_caps
|
|
min_endgame_level_cap = int(self.options.min_endgame_level_cap)
|
|
|
|
menu = Region("Menu", self.player, self.multiworld)
|
|
finalboss = Region("FinalBoss", self.player, self.multiworld)
|
|
|
|
self.multiworld.regions.append(menu)
|
|
self.multiworld.regions.append(finalboss)
|
|
|
|
self.add_location_to_region("Defeat Formortiis", None, finalboss)
|
|
|
|
def level_cap_at_least(n: int) -> Callable[[CollectionState], bool]:
|
|
player = self.player
|
|
|
|
def wrapped(state: CollectionState) -> bool:
|
|
return 10 + state.count("Progressive Level Cap", player) * 5 >= n
|
|
|
|
return wrapped
|
|
|
|
def finalboss_rule(state: CollectionState) -> bool:
|
|
if not level_cap_at_least(min_endgame_level_cap)(state):
|
|
return False
|
|
weapons_needed = self.progression_holy_weapons
|
|
weapon_types_needed = {HOLY_WEAPONS[weapon] for weapon in weapons_needed}
|
|
|
|
for weapon in weapons_needed:
|
|
if not state.has(weapon, self.player):
|
|
return False
|
|
|
|
for weapon_type in weapon_types_needed:
|
|
if (
|
|
state.count(
|
|
"Progressive Weapon Level ({})".format(weapon_type), self.player
|
|
)
|
|
< NUM_WEAPON_LEVELS
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
if smooth_level_caps:
|
|
prologue = Region("Before Routesplit", self.player, self.multiworld)
|
|
route_split = Region("Routesplit", self.player, self.multiworld)
|
|
lategame = Region("Post-routesplit", self.player, self.multiworld)
|
|
|
|
self.multiworld.regions.append(prologue)
|
|
self.multiworld.regions.append(route_split)
|
|
self.multiworld.regions.append(lategame)
|
|
|
|
self.add_location_to_region("Complete Prologue", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 1", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 2", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 3", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 4", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 5", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 5x", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 6", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 7", None, prologue)
|
|
self.add_location_to_region("Complete Chapter 8", None, prologue)
|
|
|
|
self.add_location_to_region("Complete Chapter 9", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 10", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 11", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 12", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 13", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 14", None, route_split)
|
|
self.add_location_to_region("Complete Chapter 15", None, route_split)
|
|
self.add_location_to_region("Garm Received", None, route_split)
|
|
self.add_location_to_region("Gleipnir Received", None, route_split)
|
|
self.add_location_to_region("Audhulma Received", None, route_split)
|
|
self.add_location_to_region("Excalibur Received", None, route_split)
|
|
|
|
self.add_location_to_region("Complete Chapter 16", None, lategame)
|
|
self.add_location_to_region("Complete Chapter 17", None, lategame)
|
|
self.add_location_to_region("Complete Chapter 18", None, lategame)
|
|
self.add_location_to_region("Complete Chapter 19", None, lategame)
|
|
self.add_location_to_region("Complete Chapter 20", None, lategame)
|
|
self.add_location_to_region("Defeat Lyon", None, lategame)
|
|
self.add_location_to_region("Sieglinde Received", None, lategame)
|
|
self.add_location_to_region("Siegmund Received", None, lategame)
|
|
self.add_location_to_region("Nidhogg Received", None, lategame)
|
|
self.add_location_to_region("Vidofnir Received", None, lategame)
|
|
self.add_location_to_region("Ivaldi Received", None, lategame)
|
|
self.add_location_to_region("Latona Received", None, lategame)
|
|
|
|
menu.connect(prologue, "Start Game")
|
|
prologue.add_exits(
|
|
{"Routesplit": "Clear chapter 8"},
|
|
{"Routesplit": level_cap_at_least(15)},
|
|
)
|
|
route_split.add_exits(
|
|
{"Post-routesplit": "Clear chapter 15"},
|
|
{"Post-routesplit": level_cap_at_least(25)},
|
|
)
|
|
lategame.add_exits(
|
|
{"FinalBoss": "Clear chapter 20"},
|
|
{"FinalBoss": finalboss_rule},
|
|
)
|
|
else:
|
|
campaign = Region("Campaign", self.player, self.multiworld)
|
|
|
|
for name, lid in locations:
|
|
# TODO (cam): do this better
|
|
if any(item in name for item in ("Formortiis", "Valni", "Lagdou")):
|
|
continue
|
|
self.add_location_to_region(name, lid, campaign)
|
|
|
|
menu.connect(campaign, "Start Game")
|
|
campaign.add_exits(
|
|
{"FinalBoss": "Clear chapter 20"},
|
|
{"FinalBoss": finalboss_rule},
|
|
)
|
|
|
|
self.multiworld.regions.append(campaign)
|
|
|
|
if self.options.tower_checks_enabled():
|
|
tower = Region("Tower of Valni", self.player, self.multiworld)
|
|
self.multiworld.regions.append(tower)
|
|
|
|
self.add_location_to_region("Complete Tower of Valni 1", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 2", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 3", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 4", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 5", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 6", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 7", None, tower)
|
|
self.add_location_to_region("Complete Tower of Valni 8", None, tower)
|
|
|
|
if smooth_level_caps:
|
|
route_split.add_exits(
|
|
{"Tower of Valni": "Complete Chapter 15"},
|
|
{"Tower of Valni": level_cap_at_least(20)},
|
|
)
|
|
tower.add_exits(
|
|
{"Post-routesplit": "Complete Tower of Valni 8"},
|
|
{"Post-routesplit": level_cap_at_least(25)},
|
|
)
|
|
else:
|
|
campaign.add_exits({"Tower of Valni": "Complete Chapter 15"})
|
|
tower.add_exits({"Campaign": "Complete Tower of Valni 8"})
|
|
|
|
if self.options.ruins_checks_enabled():
|
|
ruins = Region("Lagdou Ruins", self.player, self.multiworld)
|
|
self.multiworld.regions.append(ruins)
|
|
|
|
self.add_location_to_region("Complete Lagdou Ruins 1", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 2", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 3", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 4", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 5", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 6", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 7", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 8", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 9", None, ruins)
|
|
self.add_location_to_region("Complete Lagdou Ruins 10", None, ruins)
|
|
|
|
if smooth_level_caps:
|
|
lategame.add_exits(
|
|
{"Lagdou Ruins": "Complete Chapter 19"},
|
|
{"Lagdou Ruins": finalboss_rule},
|
|
)
|
|
ruins.add_exits({"Post-routesplit": "Complete Lagdou Ruins 10"})
|
|
else:
|
|
campaign.add_exits({"Lagdou Ruins": "Complete Chapter 19"})
|
|
ruins.add_exits({"Campaign": "Complete Lagdou Ruins 10"})
|
|
|
|
def fill_slot_data(self) -> dict[str, Any]:
|
|
slot_data = self.options.as_dict("goal")
|
|
return slot_data
|
|
|
|
def generate_output(self, output_directory: str) -> None:
|
|
patch = FE8ProcedurePatch(
|
|
player=self.player, player_name=self.multiworld.player_name[self.player]
|
|
)
|
|
basepatch = pkgutil.get_data(__name__, "data/base_patch.bsdiff4")
|
|
assert basepatch is not None
|
|
patch.write_file("base_patch.bsdiff4", basepatch)
|
|
write_tokens(self, patch)
|
|
rom_path = os.path.join(
|
|
output_directory,
|
|
f"{self.multiworld.get_out_file_name_base(self.player)}"
|
|
f"{patch.patch_file_ending}",
|
|
)
|
|
patch.write(rom_path)
|