diff --git a/.gitignore b/.gitignore index 4cb23a47..c0090363 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *_Spoiler.txt *.bmbp *.apbp +*.apl2ac *.apm3 *.apmc *.apz5 @@ -135,6 +136,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.code-workspace # Spyder project settings .spyderproject diff --git a/README.md b/README.md index a8f269f5..42493b59 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Currently, the following games are supported: * Hylics 2 * Overcooked! 2 * Zillion +* Lufia II Ancient Cave For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/Utils.py b/Utils.py index fd6435d6..56874651 100644 --- a/Utils.py +++ b/Utils.py @@ -307,6 +307,9 @@ def get_default_options() -> OptionsType: "ffr_options": { "display_msgs": True, }, + "lufia2ac_options": { + "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", + }, } return options diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 89972451..8f366d4f 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -71,7 +71,7 @@ def create(): del file_data - with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f: + with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f: f.write(res) # Generate JSON files for player-settings pages diff --git a/host.yaml b/host.yaml index f2d90c87..d1b0b786 100644 --- a/host.yaml +++ b/host.yaml @@ -93,6 +93,9 @@ sni_options: lttp_options: # File name of the v1.0 J rom rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" +lufia2ac_options: + # File name of the US rom + rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc" sm_options: # File name of the v1.0 J rom rom_file: "Super Metroid (JU).sfc" diff --git a/inno_setup.iss b/inno_setup.iss index d8901c56..f7748ced 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -57,6 +57,7 @@ Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full ho Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning +Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning @@ -69,6 +70,7 @@ Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Se Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing @@ -90,6 +92,7 @@ Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).s Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3 Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe +Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r @@ -191,6 +194,11 @@ Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Arch Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni +Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni + Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft @@ -263,6 +271,9 @@ var SMWRomFilePage: TInputFileWizardPage; var soerom: string; var SoERomFilePage: TInputFileWizardPage; +var l2acrom: string; +var L2ACROMFilePage: TInputFileWizardPage; + var ootrom: string; var OoTROMFilePage: TInputFileWizardPage; @@ -423,6 +434,8 @@ begin Result := not (SMWROMFilePage.Values[0] = '') else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then Result := not (SoEROMFilePage.Values[0] = '') + else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then + Result := not (L2ACROMFilePage.Values[0] = '') else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then Result := not (OoTROMFilePage.Values[0] = '') else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then @@ -527,6 +540,22 @@ begin Result := ''; end; +function GetL2ACROMPath(Param: string): string; +begin + if Length(l2acrom) > 0 then + Result := l2acrom + else if Assigned(L2ACROMFilePage) then + begin + R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d') + if R <> 0 then + MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); + + Result := L2ACROMFilePage.Values[0] + end + else + Result := ''; +end; + function GetZlROMPath(Param: string): string; begin if Length(zlrom) > 0 then @@ -610,6 +639,10 @@ begin bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b'); if Length(bluerom) = 0 then BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb'); + + l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d'); + if Length(l2acrom) = 0 then + L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc'); end; @@ -624,6 +657,8 @@ begin Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3')); if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw')); + if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then + Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac')); if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then Result := not (WizardIsComponentSelected('generator/soe')); if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then diff --git a/worlds/lufia2ac/Client.py b/worlds/lufia2ac/Client.py new file mode 100644 index 00000000..002b257d --- /dev/null +++ b/worlds/lufia2ac/Client.py @@ -0,0 +1,360 @@ +import logging +import time +import typing +from logging import Logger +from typing import Dict + +from NetUtils import ClientStatus, NetworkItem +from worlds.AutoSNIClient import SNIClient +from .Items import start_id as items_start_id +from .Locations import start_id as locations_start_id + +if typing.TYPE_CHECKING: + from SNIClient import SNIContext +else: + SNIContext = typing.Any + +snes_logger: Logger = logging.getLogger("SNES") + +SRAM_START: int = 0xE00000 +L2AC_ROMNAME_START: int = 0x007FC0 +L2AC_SIGN_ADDR: int = SRAM_START + 0x2000 +L2AC_GOAL_ADDR: int = SRAM_START + 0x2030 +L2AC_DEATH_ADDR: int = SRAM_START + 0x203D +L2AC_TX_ADDR: int = SRAM_START + 0x2040 +L2AC_RX_ADDR: int = SRAM_START + 0x2800 + +enemy_names: Dict[int, str] = { + 0x00: "a Goblin", + 0x01: "an Armor goblin", + 0x02: "a Regal Goblin", + 0x03: "a Goblin Mage", + 0x04: "a Troll", + 0x05: "an Ork", + 0x06: "a Fighter ork", + 0x07: "an Ork Mage", + 0x08: "a Lizardman", + 0x09: "a Skull Lizard", + 0x0A: "an Armour Dait", + 0x0B: "a Dragonian", + 0x0C: "a Cyclops", + 0x0D: "a Mega Cyclops", + 0x0E: "a Flame genie", + 0x0F: "a Well Genie", + 0x10: "a Wind Genie", + 0x11: "an Earth Genie", + 0x12: "a Cobalt", + 0x13: "a Merman", + 0x14: "an Aqualoi", + 0x15: "an Imp", + 0x16: "a Fiend", + 0x17: "an Archfiend", + 0x18: "a Hound", + 0x19: "a Doben", + 0x1A: "a Winger", + 0x1B: "a Serfaco", + 0x1C: "a Pug", + 0x1D: "a Salamander", + 0x1E: "a Brinz Lizard", + 0x1F: "a Seahorse", + 0x20: "a Seirein", + 0x21: "an Earth Viper", + 0x22: "a Gnome", + 0x23: "a Wispy", + 0x24: "a Thunderbeast", + 0x25: "a Lunar bear", + 0x26: "a Shadowfly", + 0x27: "a Shadow", + 0x28: "a Lion", + 0x29: "a Sphinx", + 0x2A: "a Mad horse", + 0x2B: "an Armor horse", + 0x2C: "a Buffalo", + 0x2D: "a Bruse", + 0x2E: "a Bat", + 0x2F: "a Big Bat", + 0x30: "a Red Bat", + 0x31: "an Eagle", + 0x32: "a Hawk", + 0x33: "a Crow", + 0x34: "a Baby Frog", + 0x35: "a King Frog", + 0x36: "a Lizard", + 0x37: "a Newt", + 0x38: "a Needle Lizard", + 0x39: "a Poison Lizard", + 0x3A: "a Medusa", + 0x3B: "a Ramia", + 0x3C: "a Basilisk", + 0x3D: "a Cokatoris", + 0x3E: "a Scorpion", + 0x3F: "an Antares", + 0x40: "a Small Crab", + 0x41: "a Big Crab", + 0x42: "a Red Lobster", + 0x43: "a Spider", + 0x44: "a Web Spider", + 0x45: "a Beetle", + 0x46: "a Poison Beetle", + 0x47: "a Mosquito", + 0x48: "a Coridras", + 0x49: "a Spinner", + 0x4A: "a Tartona", + 0x4B: "an Armour Nail", + 0x4C: "a Moth", + 0x4D: "a Mega Moth", + 0x4E: "a Big Bee", + 0x4F: "a Dark Fly", + 0x50: "a Stinger", + 0x51: "an Armor Bee", + 0x52: "a Sentopez", + 0x53: "a Cancer", + 0x54: "a Garbost", + 0x55: "a Bolt Fish", + 0x56: "a Moray", + 0x57: "a She Viper", + 0x58: "an Angler fish", + 0x59: "a Unicorn", + 0x5A: "an Evil Shell", + 0x5B: "a Drill Shell", + 0x5C: "a Snell", + 0x5D: "an Ammonite", + 0x5E: "an Evil Fish", + 0x5F: "a Squid", + 0x60: "a Kraken", + 0x61: "a Killer Whale", + 0x62: "a White Whale", + 0x63: "a Grianos", + 0x64: "a Behemoth", + 0x65: "a Perch", + 0x66: "a Current", + 0x67: "a Vampire Rose", + 0x68: "a Desert Rose", + 0x69: "a Venus Fly", + 0x6A: "a Moray Vine", + 0x6B: "a Torrent", + 0x6C: "a Mad Ent", + 0x6D: "a Crow Kelp", + 0x6E: "a Red Plant", + 0x6F: "La Fleshia", + 0x70: "a Wheel Eel", + 0x71: "a Skeleton", + 0x72: "a Ghoul", + 0x73: "a Zombie", + 0x74: "a Specter", + 0x75: "a Dark Spirit", + 0x76: "a Snatcher", + 0x77: "a Jurahan", + 0x78: "a Demise", + 0x79: "a Leech", + 0x7A: "a Necromancer", + 0x7B: "a Hade Chariot", + 0x7C: "a Hades", + 0x7D: "a Dark Skull", + 0x7E: "a Hades Skull", + 0x7F: "a Mummy", + 0x80: "a Vampire", + 0x81: "a Nosferato", + 0x82: "a Ghost Ship", + 0x83: "a Deadly Sword", + 0x84: "a Deadly Armor", + 0x85: "a T Rex", + 0x86: "a Brokion", + 0x87: "a Pumpkin Head", + 0x88: "a Mad Head", + 0x89: "a Snow Gas", + 0x8A: "a Great Coca", + 0x8B: "a Gargoyle", + 0x8C: "a Rogue Shape", + 0x8D: "a Bone Gorem", + 0x8E: "a Nuborg", + 0x8F: "a Wood Gorem", + 0x90: "a Mad Gorem", + 0x91: "a Green Clay", + 0x92: "a Sand Gorem", + 0x93: "a Magma Gorem", + 0x94: "an Iron Gorem", + 0x95: "a Gold Gorem", + 0x96: "a Hidora", + 0x97: "a Sea Hidora", + 0x98: "a High Hidora", + 0x99: "a King Hidora", + 0x9A: "an Orky", + 0x9B: "a Waiban", + 0x9C: "a White Dragon", + 0x9D: "a Red Dragon", + 0x9E: "a Blue Dragon", + 0x9F: "a Green Dragon", + 0xA0: "a Black Dragon", + 0xA1: "a Copper Dragon", + 0xA2: "a Silver Dragon", + 0xA3: "a Gold Dragon", + 0xA4: "a Red Jelly", + 0xA5: "a Blue Jelly", + 0xA6: "a Bili Jelly", + 0xA7: "a Red Core", + 0xA8: "a Blue Core", + 0xA9: "a Green Core", + 0xAA: "a No Core", + 0xAB: "a Mimic", + 0xAC: "a Blue Mimic", + 0xAD: "an Ice Roge", + 0xAE: "a Mushroom", + 0xAF: "a Big Mushr'm", + 0xB0: "a Minataurus", + 0xB1: "a Gorgon", + 0xB2: "a Ninja", + 0xB3: "an Asashin", + 0xB4: "a Samurai", + 0xB5: "a Dark Warrior", + 0xB6: "an Ochi Warrior", + 0xB7: "a Sly Fox", + 0xB8: "a Tengu", + 0xB9: "a Warm Eye", + 0xBA: "a Wizard", + 0xBB: "a Dark Sum'ner", + 0xBC: "the Big Catfish", + 0xBD: "a Follower", + 0xBE: "the Tarantula", + 0xBF: "Pierre", + 0xC0: "Daniele", + 0xC1: "the Venge Ghost", + 0xC2: "the Fire Dragon", + 0xC3: "the Tank", + 0xC4: "Idura", + 0xC5: "Camu", + 0xC6: "Gades", + 0xC7: "Amon", + 0xC8: "Erim", + 0xC9: "Daos", + 0xCA: "a Lizard Man", + 0xCB: "a Goblin", + 0xCC: "a Skeleton", + 0xCD: "a Regal Goblin", + 0xCE: "a Goblin", + 0xCF: "a Goblin Mage", + 0xD0: "a Slave", + 0xD1: "a Follower", + 0xD2: "a Groupie", + 0xD3: "the Egg Dragon", + 0xD4: "a Mummy", + 0xD5: "a Troll", + 0xD6: "Gades", + 0xD7: "Idura", + 0xD8: "a Lion", + 0xD9: "the Rogue Flower", + 0xDA: "a Gargoyle", + 0xDB: "a Ghost Ship", + 0xDC: "Idura", + 0xDD: "a Soldier", + 0xDE: "Gades", + 0xDF: "the Master", +} + + +class L2ACSNIClient(SNIClient): + game: str = "Lufia II Ancient Cave" + + async def validate_rom(self, ctx: SNIContext) -> bool: + from SNIClient import snes_read + + rom_name: bytes = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) + if rom_name is None or rom_name[:4] != b"L2AC": + return False + + ctx.game = self.game + ctx.items_handling = 0b111 # fully remote + + ctx.rom = rom_name + + return True + + async def game_watcher(self, ctx: SNIContext) -> None: + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom: bytes = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) + if rom != ctx.rom: + ctx.rom = None + return + + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + signature: bytes = await snes_read(ctx, L2AC_SIGN_ADDR, 16) + if signature != b"ArchipelagoLufia": + return + + # Goal + if not ctx.finished_game: + goal_data: bytes = await snes_read(ctx, L2AC_GOAL_ADDR, 10) + if goal_data is not None and goal_data[goal_data[0]] == 0x01: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + # DeathLink TX + death_data: bytes = await snes_read(ctx, L2AC_DEATH_ADDR, 3) + if death_data is not None: + await ctx.update_death_link(bool(death_data[0])) + if death_data[1] != 0x00: + snes_buffered_write(ctx, L2AC_DEATH_ADDR + 1, b"\x00") + if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): + player_name: str = ctx.player_names.get(ctx.slot, str(ctx.slot)) + enemy_name: str = enemy_names.get(death_data[1] - 1, hex(death_data[1] - 1)) + await ctx.send_death(f"{player_name} was totally defeated by {enemy_name}.") + + # TX + tx_data: bytes = await snes_read(ctx, L2AC_TX_ADDR, 8) + if tx_data is not None: + snes_items_sent = int.from_bytes(tx_data[:2], "little") + client_items_sent = int.from_bytes(tx_data[2:4], "little") + client_ap_items_found = int.from_bytes(tx_data[4:6], "little") + + if client_items_sent < snes_items_sent: + location_id: int = locations_start_id + client_items_sent + location: str = ctx.location_names[location_id] + client_items_sent += 1 + + ctx.locations_checked.add(location_id) + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + snes_logger.info("New Check: %s (%d/%d)" % ( + location, + len(ctx.locations_checked), + len(ctx.missing_locations) + len(ctx.checked_locations))) + snes_buffered_write(ctx, L2AC_TX_ADDR + 2, client_items_sent.to_bytes(2, "little")) + + ap_items_found: int = sum(net_item.player != ctx.slot for net_item in ctx.locations_info.values()) + if client_ap_items_found < ap_items_found: + snes_buffered_write(ctx, L2AC_TX_ADDR + 4, ap_items_found.to_bytes(2, "little")) + + # RX + rx_data: bytes = await snes_read(ctx, L2AC_RX_ADDR, 4) + if rx_data is not None: + snes_items_received = int.from_bytes(rx_data[:2], "little") + + if snes_items_received < len(ctx.items_received): + item: NetworkItem = ctx.items_received[snes_items_received] + item_code: int = item.item - items_start_id + snes_items_received += 1 + + snes_logger.info("Received %s from %s (%s) (%d/%d in list)" % ( + ctx.item_names[item.item], + ctx.player_names[item.player], + ctx.location_names[item.location], + snes_items_received, len(ctx.items_received))) + snes_buffered_write(ctx, L2AC_RX_ADDR + 2 * (snes_items_received + 1), item_code.to_bytes(2, 'little')) + snes_buffered_write(ctx, L2AC_RX_ADDR, snes_items_received.to_bytes(2, "little")) + + await snes_flush_writes(ctx) + + async def deathlink_kill_player(self, ctx: SNIContext) -> None: + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes + + # DeathLink RX + if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): + snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x01") + else: + snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x00") + await snes_flush_writes(ctx) + ctx.death_state = DeathState.dead diff --git a/worlds/lufia2ac/Items.py b/worlds/lufia2ac/Items.py new file mode 100644 index 00000000..cfdc0b8c --- /dev/null +++ b/worlds/lufia2ac/Items.py @@ -0,0 +1,552 @@ +from enum import auto, Enum +from typing import Dict, NamedTuple, Optional + +from BaseClasses import Item, ItemClassification +from . import Locations + +start_id: int = Locations.start_id + + +class ItemType(Enum): + BLUE_CHEST = auto() + CAPSULE_MONSTER = auto() + ENEMY_DROP = auto() + ENTRANCE_CHEST = auto() + PARTY_MEMBER = auto() + RED_CHEST = auto() + RED_CHEST_PATCH = auto() + + +class ItemData(NamedTuple): + code: int + type: ItemType + classification: ItemClassification + + +class L2ACItem(Item): + game: str = "Lufia II Ancient Cave" + + def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int): + super().__init__(name, classification, code, player) + + +l2ac_item_table: Dict[str, ItemData] = { + # 0x0000: "No equip" + # ----- CONSUMABLE ----- + "Charred newt": ItemData(0x0001, ItemType.ENEMY_DROP, ItemClassification.useful), + "Potion": ItemData(0x0002, ItemType.RED_CHEST, ItemClassification.useful), + "Hi-Potion": ItemData(0x0003, ItemType.RED_CHEST, ItemClassification.useful), + "Ex-Potion": ItemData(0x0004, ItemType.RED_CHEST, ItemClassification.useful), + "Magic jar": ItemData(0x0005, ItemType.ENEMY_DROP, ItemClassification.useful), + "Hi-Magic": ItemData(0x0006, ItemType.RED_CHEST, ItemClassification.useful), + "Ex-Magic": ItemData(0x0007, ItemType.RED_CHEST, ItemClassification.useful), + "Regain": ItemData(0x0008, ItemType.RED_CHEST, ItemClassification.useful), + "Miracle": ItemData(0x0009, ItemType.RED_CHEST, ItemClassification.useful), + "Antidote": ItemData(0x000A, ItemType.RED_CHEST, ItemClassification.useful), + "Awake": ItemData(0x000B, ItemType.RED_CHEST, ItemClassification.useful), + "Shriek": ItemData(0x000C, ItemType.RED_CHEST, ItemClassification.useful), + "Mystery pin": ItemData(0x000D, ItemType.RED_CHEST, ItemClassification.useful), + "Power gourd": ItemData(0x000E, ItemType.RED_CHEST, ItemClassification.useful), + "Mind gourd": ItemData(0x000F, ItemType.RED_CHEST, ItemClassification.useful), + "Magic guard": ItemData(0x0010, ItemType.RED_CHEST, ItemClassification.useful), + "Life potion": ItemData(0x0011, ItemType.RED_CHEST, ItemClassification.useful), + "Spell potion": ItemData(0x0012, ItemType.RED_CHEST, ItemClassification.useful), + "Power potion": ItemData(0x0013, ItemType.RED_CHEST, ItemClassification.useful), + "Speed potion": ItemData(0x0014, ItemType.RED_CHEST, ItemClassification.useful), + "Mind potion": ItemData(0x0015, ItemType.RED_CHEST, ItemClassification.useful), + "Brave": ItemData(0x0016, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0017: "Pear cider" + "Sour cider": ItemData(0x0018, ItemType.ENEMY_DROP, ItemClassification.useful), + # 0x0019: "Lime cider" + # 0x001A: "Plum cider" + # 0x001B: "Apple cider" + "Sleep ball": ItemData(0x001C, ItemType.RED_CHEST, ItemClassification.useful), + "Confuse ball": ItemData(0x001D, ItemType.RED_CHEST, ItemClassification.useful), + "Freeze ball": ItemData(0x001E, ItemType.RED_CHEST, ItemClassification.useful), + "Smoke ball": ItemData(0x001F, ItemType.RED_CHEST, ItemClassification.useful), + "Ice ball": ItemData(0x0020, ItemType.RED_CHEST, ItemClassification.useful), + "Fire ball": ItemData(0x0021, ItemType.RED_CHEST, ItemClassification.useful), + "Terror ball": ItemData(0x0022, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0023: "Ear pick" + "Boomerang": ItemData(0x0024, ItemType.RED_CHEST, ItemClassification.useful), + "Big boomer": ItemData(0x0025, ItemType.RED_CHEST, ItemClassification.useful), + "Ex-boomer": ItemData(0x0026, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0027: "Dragon tooth" + # 0x0028: "Green tea" + # 0x0029: "Escape" + # 0x002A: "Warp" + # 0x002B: "Dragon egg" + "Curselifter": ItemData(0x002C, ItemType.RED_CHEST, ItemClassification.useful), + # 0x002D: "Providence" + "Secret fruit": ItemData(0x002E, ItemType.ENEMY_DROP, ItemClassification.useful), + "Holy fruit": ItemData(0x002F, ItemType.ENEMY_DROP, ItemClassification.useful), + "Breeze fruit": ItemData(0x0030, ItemType.ENEMY_DROP, ItemClassification.useful), + "Charm fruit": ItemData(0x0031, ItemType.ENEMY_DROP, ItemClassification.useful), + "Dark fruit": ItemData(0x0032, ItemType.ENEMY_DROP, ItemClassification.useful), + "Earth fruit": ItemData(0x0033, ItemType.ENEMY_DROP, ItemClassification.useful), + "Flame fruit": ItemData(0x0034, ItemType.ENEMY_DROP, ItemClassification.useful), + "Magic fruit": ItemData(0x0035, ItemType.RED_CHEST, ItemClassification.useful), + # ----- WEAPON ----- + # 0x0036: "Dual blade" + "Frypan": ItemData(0x0037, ItemType.RED_CHEST, ItemClassification.useful), + "Knife": ItemData(0x0038, ItemType.RED_CHEST, ItemClassification.useful), + "Small knife": ItemData(0x0039, ItemType.RED_CHEST, ItemClassification.useful), + "Rapier": ItemData(0x003A, ItemType.RED_CHEST, ItemClassification.useful), + "Battle knife": ItemData(0x003B, ItemType.RED_CHEST, ItemClassification.useful), + "Dagger": ItemData(0x003C, ItemType.RED_CHEST, ItemClassification.useful), + "Insect crush": ItemData(0x003D, ItemType.RED_CHEST, ItemClassification.useful), + "Long knife": ItemData(0x003E, ItemType.RED_CHEST, ItemClassification.useful), + "Short sword": ItemData(0x003F, ItemType.RED_CHEST, ItemClassification.useful), + "Light knife": ItemData(0x0040, ItemType.RED_CHEST, ItemClassification.useful), + "Kukri": ItemData(0x0041, ItemType.RED_CHEST, ItemClassification.useful), + "Gladius": ItemData(0x0042, ItemType.RED_CHEST, ItemClassification.useful), + "Cold rapier": ItemData(0x0043, ItemType.RED_CHEST, ItemClassification.useful), + "Scimitar": ItemData(0x0044, ItemType.RED_CHEST, ItemClassification.useful), + "Deadly sword": ItemData(0x0045, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0046: "Deadly sword" (uncursed) + "SuhrCustom11": ItemData(0x0047, ItemType.RED_CHEST, ItemClassification.useful), + "Bronze sword": ItemData(0x0048, ItemType.RED_CHEST, ItemClassification.useful), + "Fire dagger": ItemData(0x0049, ItemType.RED_CHEST, ItemClassification.useful), + "War rapier": ItemData(0x004A, ItemType.RED_CHEST, ItemClassification.useful), + "Long sword": ItemData(0x004B, ItemType.RED_CHEST, ItemClassification.useful), + "Beserk blade": ItemData(0x004C, ItemType.RED_CHEST, ItemClassification.useful), + # 0x004D: "Beserk blade" (uncursed) + "Multi sword": ItemData(0x004E, ItemType.RED_CHEST, ItemClassification.useful), + "Rockbreaker": ItemData(0x004F, ItemType.RED_CHEST, ItemClassification.useful), + "Broadsword": ItemData(0x0050, ItemType.RED_CHEST, ItemClassification.useful), + "Estok": ItemData(0x0051, ItemType.RED_CHEST, ItemClassification.useful), + "Silvo rapier": ItemData(0x0052, ItemType.RED_CHEST, ItemClassification.useful), + "Burn sword": ItemData(0x0053, ItemType.RED_CHEST, ItemClassification.useful), + "Dekar blade": ItemData(0x0054, ItemType.RED_CHEST, ItemClassification.useful), + "Crazy blade": ItemData(0x0055, ItemType.RED_CHEST, ItemClassification.useful), + "Deadly sword (fake)": ItemData(0x0056, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0057: "Deadly sword" (fake, uncursed) + "Luck rapier": ItemData(0x0058, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0059: "Luck rapier" (uncursed) + "Aqua sword": ItemData(0x005A, ItemType.RED_CHEST, ItemClassification.useful), + "Red saber": ItemData(0x005B, ItemType.RED_CHEST, ItemClassification.useful), + "Lucky blade": ItemData(0x005C, ItemType.RED_CHEST, ItemClassification.useful), + # 0x005D: "Lucky blade" (uncursed) + "Mist rapier": ItemData(0x005E, ItemType.RED_CHEST, ItemClassification.useful), + "Boom sword": ItemData(0x005F, ItemType.RED_CHEST, ItemClassification.useful), + "Freeze sword": ItemData(0x0060, ItemType.RED_CHEST, ItemClassification.useful), + "Silver sword": ItemData(0x0061, ItemType.RED_CHEST, ItemClassification.useful), + "Flying blow": ItemData(0x0062, ItemType.RED_CHEST, ItemClassification.useful), + "Super sword": ItemData(0x0063, ItemType.RED_CHEST, ItemClassification.useful), + "Buster sword": ItemData(0x0064, ItemType.RED_CHEST, ItemClassification.useful), + "Rune rapier": ItemData(0x0065, ItemType.RED_CHEST, ItemClassification.useful), + "Old sword": ItemData(0x0066, ItemType.RED_CHEST, ItemClassification.useful), + "Lizard blow": ItemData(0x0067, ItemType.RED_CHEST, ItemClassification.useful), + "Zirco sword": ItemData(0x0068, ItemType.RED_CHEST, ItemClassification.useful), + "Sizzle sword": ItemData(0x4069, ItemType.BLUE_CHEST, ItemClassification.useful), + "Blaze sword": ItemData(0x406A, ItemType.BLUE_CHEST, ItemClassification.useful), + "Myth blade": ItemData(0x006B, ItemType.RED_CHEST, ItemClassification.useful), + "Gades blade": ItemData(0x406C, ItemType.BLUE_CHEST, ItemClassification.useful), + "Sky sword": ItemData(0x406D, ItemType.BLUE_CHEST, ItemClassification.useful), + "Snow sword": ItemData(0x406E, ItemType.BLUE_CHEST, ItemClassification.useful), + "Fry sword": ItemData(0x406F, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x0070: "Egg sword" + "Franshiska": ItemData(0x0071, ItemType.RED_CHEST, ItemClassification.useful), + "Thunder ax": ItemData(0x0072, ItemType.RED_CHEST, ItemClassification.useful), + "Hand ax": ItemData(0x0073, ItemType.RED_CHEST, ItemClassification.useful), + "Bronze ax": ItemData(0x0074, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0075: "Flying ax" + "Rainy ax": ItemData(0x0076, ItemType.RED_CHEST, ItemClassification.useful), + "Great ax": ItemData(0x0077, ItemType.RED_CHEST, ItemClassification.useful), + "Zirco ax": ItemData(0x0078, ItemType.RED_CHEST, ItemClassification.useful), + "Mega ax": ItemData(0x4079, ItemType.BLUE_CHEST, ItemClassification.useful), + "Mace": ItemData(0x007A, ItemType.RED_CHEST, ItemClassification.useful), + "Rod": ItemData(0x007B, ItemType.RED_CHEST, ItemClassification.useful), + "Staff": ItemData(0x007C, ItemType.RED_CHEST, ItemClassification.useful), + "Deadly rod": ItemData(0x007D, ItemType.RED_CHEST, ItemClassification.useful), + # 0x007E: "Deadly rod" (uncursed) + "Sleep rod": ItemData(0x007F, ItemType.RED_CHEST, ItemClassification.useful), + "Long staff": ItemData(0x0080, ItemType.RED_CHEST, ItemClassification.useful), + "Holy staff": ItemData(0x0081, ItemType.RED_CHEST, ItemClassification.useful), + "Morning star": ItemData(0x0082, ItemType.RED_CHEST, ItemClassification.useful), + "Pounder rod": ItemData(0x0083, ItemType.RED_CHEST, ItemClassification.useful), + "Crystal wand": ItemData(0x0084, ItemType.RED_CHEST, ItemClassification.useful), + "Silver rod": ItemData(0x0085, ItemType.RED_CHEST, ItemClassification.useful), + "Zirco rod": ItemData(0x0086, ItemType.RED_CHEST, ItemClassification.useful), + "Zirco flail": ItemData(0x0087, ItemType.RED_CHEST, ItemClassification.useful), + "Spark staff": ItemData(0x4088, ItemType.BLUE_CHEST, ItemClassification.useful), + "Whip": ItemData(0x0089, ItemType.RED_CHEST, ItemClassification.useful), + "Wire": ItemData(0x008A, ItemType.RED_CHEST, ItemClassification.useful), + "Chain": ItemData(0x008B, ItemType.RED_CHEST, ItemClassification.useful), + "Aqua whip": ItemData(0x008C, ItemType.RED_CHEST, ItemClassification.useful), + "Cutter whip": ItemData(0x008D, ItemType.RED_CHEST, ItemClassification.useful), + "Royal whip": ItemData(0x008E, ItemType.RED_CHEST, ItemClassification.useful), + "Holy whip": ItemData(0x008F, ItemType.RED_CHEST, ItemClassification.useful), + "Zirco whip": ItemData(0x0090, ItemType.RED_CHEST, ItemClassification.useful), + "Air whip": ItemData(0x4091, ItemType.BLUE_CHEST, ItemClassification.useful), + "Fatal pick": ItemData(0x0092, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0093: "Fatal pick" (uncursed) + "Spear": ItemData(0x0094, ItemType.RED_CHEST, ItemClassification.useful), + "Trident": ItemData(0x0095, ItemType.RED_CHEST, ItemClassification.useful), + "Halberd": ItemData(0x0096, ItemType.RED_CHEST, ItemClassification.useful), + "Heavy lance": ItemData(0x0097, ItemType.RED_CHEST, ItemClassification.useful), + "Water spear": ItemData(0x4098, ItemType.BLUE_CHEST, ItemClassification.useful), + "Dragon spear": ItemData(0x4099, ItemType.BLUE_CHEST, ItemClassification.useful), + "Vice pliers": ItemData(0x009A, ItemType.RED_CHEST, ItemClassification.useful), + "Coma hit": ItemData(0x009B, ItemType.RED_CHEST, ItemClassification.useful), + "Figgoru": ItemData(0x009C, ItemType.RED_CHEST, ItemClassification.useful), + "Superdriver": ItemData(0x009D, ItemType.RED_CHEST, ItemClassification.useful), + "Stun gun": ItemData(0x009E, ItemType.RED_CHEST, ItemClassification.useful), + "Battledriver": ItemData(0x009F, ItemType.RED_CHEST, ItemClassification.useful), + "Launcher": ItemData(0x00A0, ItemType.RED_CHEST, ItemClassification.useful), + "Freeze bow": ItemData(0x00A1, ItemType.RED_CHEST, ItemClassification.useful), + "Cursed bow": ItemData(0x00A2, ItemType.RED_CHEST, ItemClassification.useful), + # 0x00A3: "Arty's bow" (uncursed) + # ----- ARMOR ----- + "Apron": ItemData(0x00A4, ItemType.RED_CHEST, ItemClassification.useful), + "Dress": ItemData(0x00A5, ItemType.RED_CHEST, ItemClassification.useful), + "Cloth": ItemData(0x00A6, ItemType.RED_CHEST, ItemClassification.useful), + "Lab-coat": ItemData(0x00A7, ItemType.RED_CHEST, ItemClassification.useful), + "Hide armor": ItemData(0x00A8, ItemType.RED_CHEST, ItemClassification.useful), + "Frock": ItemData(0x00A9, ItemType.RED_CHEST, ItemClassification.useful), + "Robe": ItemData(0x00AA, ItemType.RED_CHEST, ItemClassification.useful), + "Cloth armor": ItemData(0x00AB, ItemType.RED_CHEST, ItemClassification.useful), + "Coat": ItemData(0x00AC, ItemType.RED_CHEST, ItemClassification.useful), + "Tough hide": ItemData(0x00AD, ItemType.RED_CHEST, ItemClassification.useful), + "Light dress": ItemData(0x00AE, ItemType.RED_CHEST, ItemClassification.useful), + "Light armor": ItemData(0x00AF, ItemType.RED_CHEST, ItemClassification.useful), + "Camu armor": ItemData(0x00B0, ItemType.RED_CHEST, ItemClassification.useful), + "Baggy": ItemData(0x00B1, ItemType.RED_CHEST, ItemClassification.useful), + "Tight dress": ItemData(0x00B2, ItemType.RED_CHEST, ItemClassification.useful), + "Chainmail": ItemData(0x00B3, ItemType.RED_CHEST, ItemClassification.useful), + "Holy wings": ItemData(0x00B4, ItemType.RED_CHEST, ItemClassification.useful), + "Ironmail": ItemData(0x00B5, ItemType.RED_CHEST, ItemClassification.useful), + "Toga": ItemData(0x00B6, ItemType.RED_CHEST, ItemClassification.useful), + "Chain armor": ItemData(0x00B7, ItemType.RED_CHEST, ItemClassification.useful), + "Thick cloth": ItemData(0x00B8, ItemType.RED_CHEST, ItemClassification.useful), + "Stone plate": ItemData(0x00B9, ItemType.RED_CHEST, ItemClassification.useful), + "Long robe": ItemData(0x00BA, ItemType.RED_CHEST, ItemClassification.useful), + "Plated cloth": ItemData(0x00BB, ItemType.RED_CHEST, ItemClassification.useful), + "Iron plate": ItemData(0x00BC, ItemType.RED_CHEST, ItemClassification.useful), + "Metal mail": ItemData(0x00BD, ItemType.RED_CHEST, ItemClassification.useful), + "Silk toga": ItemData(0x00BE, ItemType.RED_CHEST, ItemClassification.useful), + "Silver armor": ItemData(0x00BF, ItemType.RED_CHEST, ItemClassification.useful), + "Light jacket": ItemData(0x00C0, ItemType.RED_CHEST, ItemClassification.useful), + "Metal coat": ItemData(0x00C1, ItemType.RED_CHEST, ItemClassification.useful), + "Silver mail": ItemData(0x00C2, ItemType.RED_CHEST, ItemClassification.useful), + "Power jacket": ItemData(0x00C3, ItemType.RED_CHEST, ItemClassification.useful), + "Quilted silk": ItemData(0x00C4, ItemType.RED_CHEST, ItemClassification.useful), + "Metal armor": ItemData(0x00C5, ItemType.RED_CHEST, ItemClassification.useful), + "Power cape": ItemData(0x00C6, ItemType.RED_CHEST, ItemClassification.useful), + "Magic bikini": ItemData(0x00C7, ItemType.RED_CHEST, ItemClassification.useful), + "Silver robe": ItemData(0x00C8, ItemType.RED_CHEST, ItemClassification.useful), + "Evening gown": ItemData(0x00C9, ItemType.RED_CHEST, ItemClassification.useful), + "Plate armor": ItemData(0x00CA, ItemType.RED_CHEST, ItemClassification.useful), + "Plati plate": ItemData(0x00CB, ItemType.RED_CHEST, ItemClassification.useful), + "Silk robe": ItemData(0x00CC, ItemType.RED_CHEST, ItemClassification.useful), + "Revive armor": ItemData(0x00CD, ItemType.RED_CHEST, ItemClassification.useful), + "Crystal mail": ItemData(0x00CE, ItemType.RED_CHEST, ItemClassification.useful), + "Crystal robe": ItemData(0x00CF, ItemType.RED_CHEST, ItemClassification.useful), + "Heal armor": ItemData(0x00D0, ItemType.RED_CHEST, ItemClassification.useful), + "Metal jacket": ItemData(0x00D1, ItemType.RED_CHEST, ItemClassification.useful), + "Deadly armor": ItemData(0x00D2, ItemType.RED_CHEST, ItemClassification.useful), + # 0x00D3: "Deadly armor" (uncursed) + "Eron dress": ItemData(0x00D4, ItemType.RED_CHEST, ItemClassification.useful), + "Bright armor": ItemData(0x00D5, ItemType.RED_CHEST, ItemClassification.useful), + "Bright cloth": ItemData(0x00D6, ItemType.RED_CHEST, ItemClassification.useful), + "Power robe": ItemData(0x00D7, ItemType.RED_CHEST, ItemClassification.useful), + "Magic scale": ItemData(0x00D8, ItemType.RED_CHEST, ItemClassification.useful), + # 0x00D9: "Holy robe" + "Ghostclothes": ItemData(0x00DA, ItemType.RED_CHEST, ItemClassification.useful), + "Royal dress": ItemData(0x00DB, ItemType.RED_CHEST, ItemClassification.useful), + "Full mail": ItemData(0x00DC, ItemType.RED_CHEST, ItemClassification.useful), + "Old armor": ItemData(0x00DD, ItemType.RED_CHEST, ItemClassification.useful), + "Zircon plate": ItemData(0x00DE, ItemType.RED_CHEST, ItemClassification.useful), + "Zircon armor": ItemData(0x00DF, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Mirak plate": ItemData(0x40E0, ItemType.BLUE_CHEST, ItemClassification.useful), + "Ruse armor": ItemData(0x40E1, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x00E2: "Pearl armor" + # ----- SHIELD ----- + "Chop board": ItemData(0x00E3, ItemType.RED_CHEST, ItemClassification.useful), + "Small shield": ItemData(0x00E4, ItemType.RED_CHEST, ItemClassification.useful), + "Hide shield": ItemData(0x00E5, ItemType.RED_CHEST, ItemClassification.useful), + "Buckler": ItemData(0x00E6, ItemType.RED_CHEST, ItemClassification.useful), + "Mini shield": ItemData(0x00E7, ItemType.RED_CHEST, ItemClassification.useful), + "Wood shield": ItemData(0x00E8, ItemType.RED_CHEST, ItemClassification.useful), + "Bracelet": ItemData(0x00E9, ItemType.RED_CHEST, ItemClassification.useful), + "Power brace": ItemData(0x00EA, ItemType.RED_CHEST, ItemClassification.useful), + "Kite shield": ItemData(0x00EB, ItemType.RED_CHEST, ItemClassification.useful), + "Tough gloves": ItemData(0x00EC, ItemType.RED_CHEST, ItemClassification.useful), + "Brone shield": ItemData(0x00ED, ItemType.RED_CHEST, ItemClassification.useful), + "Anger brace": ItemData(0x00EE, ItemType.RED_CHEST, ItemClassification.useful), + "Block shield": ItemData(0x00EF, ItemType.RED_CHEST, ItemClassification.useful), + "Tecto gloves": ItemData(0x00F0, ItemType.RED_CHEST, ItemClassification.useful), + "Round shield": ItemData(0x00F1, ItemType.RED_CHEST, ItemClassification.useful), + "Pearl brace": ItemData(0x00F2, ItemType.RED_CHEST, ItemClassification.useful), + "Fayza shield": ItemData(0x00F3, ItemType.RED_CHEST, ItemClassification.useful), + "Big shield": ItemData(0x00F4, ItemType.RED_CHEST, ItemClassification.useful), + "Tall shield": ItemData(0x00F5, ItemType.RED_CHEST, ItemClassification.useful), + "Silvo shield": ItemData(0x00F6, ItemType.RED_CHEST, ItemClassification.useful), + "Spike shield": ItemData(0x00F7, ItemType.RED_CHEST, ItemClassification.useful), + "Slash shield": ItemData(0x00F8, ItemType.RED_CHEST, ItemClassification.useful), + "Mage shield": ItemData(0x00F9, ItemType.RED_CHEST, ItemClassification.useful), + "Tuff buckler": ItemData(0x00FA, ItemType.RED_CHEST, ItemClassification.useful), + "Tect buckler": ItemData(0x00FB, ItemType.RED_CHEST, ItemClassification.useful), + "Gold gloves": ItemData(0x00FC, ItemType.RED_CHEST, ItemClassification.useful), + "Gold shield": ItemData(0x00FD, ItemType.RED_CHEST, ItemClassification.useful), + "Plati gloves": ItemData(0x00FE, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Plati shield": ItemData(0x00FF, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Gauntlet": ItemData(0x0100, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Rune gloves": ItemData(0x0101, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Holy shield": ItemData(0x0102, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Zirco gloves": ItemData(0x0103, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Zirco shield": ItemData(0x0104, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Old shield": ItemData(0x0105, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Flame shield": ItemData(0x4106, ItemType.BLUE_CHEST, ItemClassification.useful), + "Water gaunt": ItemData(0x4107, ItemType.BLUE_CHEST, ItemClassification.useful), + "Bolt shield": ItemData(0x4108, ItemType.BLUE_CHEST, ItemClassification.useful), + "Cryst shield": ItemData(0x4109, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x010A: "Mega shield" + "Dark mirror": ItemData(0x410B, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x010C: "Dark mirror" (uncursed) + "Apron shield": ItemData(0x410D, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x010E: "Pearl shield" + # ----- HEADGEAR ----- + "Pot": ItemData(0x010F, ItemType.RED_CHEST, ItemClassification.useful), + "Beret": ItemData(0x0110, ItemType.RED_CHEST, ItemClassification.useful), + "Cap": ItemData(0x0111, ItemType.RED_CHEST, ItemClassification.useful), + "Cloth helmet": ItemData(0x0112, ItemType.RED_CHEST, ItemClassification.useful), + "Hairband": ItemData(0x0113, ItemType.RED_CHEST, ItemClassification.useful), + "Headband": ItemData(0x0114, ItemType.RED_CHEST, ItemClassification.useful), + "Hide helmet": ItemData(0x0115, ItemType.RED_CHEST, ItemClassification.useful), + "Jet helm": ItemData(0x0116, ItemType.RED_CHEST, ItemClassification.useful), + "Red beret": ItemData(0x0117, ItemType.RED_CHEST, ItemClassification.useful), + "Glass cap": ItemData(0x0118, ItemType.RED_CHEST, ItemClassification.useful), + "Wood helmet": ItemData(0x0119, ItemType.RED_CHEST, ItemClassification.useful), + "Blue beret": ItemData(0x011A, ItemType.RED_CHEST, ItemClassification.useful), + "Brone helmet": ItemData(0x011B, ItemType.RED_CHEST, ItemClassification.useful), + "Stone helmet": ItemData(0x011C, ItemType.RED_CHEST, ItemClassification.useful), + "Cloche": ItemData(0x011D, ItemType.RED_CHEST, ItemClassification.useful), + "Fury helmet": ItemData(0x011E, ItemType.RED_CHEST, ItemClassification.useful), + "Iron helmet": ItemData(0x011F, ItemType.RED_CHEST, ItemClassification.useful), + "Tight helmet": ItemData(0x0120, ItemType.RED_CHEST, ItemClassification.useful), + "Turban": ItemData(0x0121, ItemType.RED_CHEST, ItemClassification.useful), + "Plate cap": ItemData(0x0122, ItemType.RED_CHEST, ItemClassification.useful), + "Roomy helmet": ItemData(0x0123, ItemType.RED_CHEST, ItemClassification.useful), + "Tight turban": ItemData(0x0124, ItemType.RED_CHEST, ItemClassification.useful), + "Glass cloche": ItemData(0x0125, ItemType.RED_CHEST, ItemClassification.useful), + "Plate helmet": ItemData(0x0126, ItemType.RED_CHEST, ItemClassification.useful), + "Rock helmet": ItemData(0x0127, ItemType.RED_CHEST, ItemClassification.useful), + "Jute helmet": ItemData(0x0128, ItemType.RED_CHEST, ItemClassification.useful), + "Shade hat": ItemData(0x0129, ItemType.RED_CHEST, ItemClassification.useful), + "Metal cloche": ItemData(0x012A, ItemType.RED_CHEST, ItemClassification.useful), + "SilverHelmet": ItemData(0x012B, ItemType.RED_CHEST, ItemClassification.useful), + "Fury ribbon": ItemData(0x012C, ItemType.RED_CHEST, ItemClassification.useful), + "Silver hat": ItemData(0x012D, ItemType.RED_CHEST, ItemClassification.useful), + "Eron hat": ItemData(0x012E, ItemType.RED_CHEST, ItemClassification.useful), + "Circlet": ItemData(0x012F, ItemType.RED_CHEST, ItemClassification.useful), + "Golden helm": ItemData(0x0130, ItemType.RED_CHEST, ItemClassification.useful), + "Gold band": ItemData(0x0131, ItemType.RED_CHEST, ItemClassification.useful), + "Plati band": ItemData(0x0132, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Plati helm": ItemData(0x0133, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Crysto beret": ItemData(0x0134, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Crysto helm": ItemData(0x0135, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Holy cap": ItemData(0x0136, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Safety hat": ItemData(0x0137, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Zirco band": ItemData(0x0138, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Zirco helmet": ItemData(0x0139, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Old helmet": ItemData(0x013A, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Agony helm": ItemData(0x413B, ItemType.BLUE_CHEST, ItemClassification.useful), + "Boom turban": ItemData(0x413C, ItemType.BLUE_CHEST, ItemClassification.useful), + "Aqua helm": ItemData(0x413D, ItemType.BLUE_CHEST, ItemClassification.useful), + "Ice hairband": ItemData(0x413E, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x013F: "Legend helm" + "Hairpin": ItemData(0x4140, ItemType.BLUE_CHEST, ItemClassification.useful), + "Brill helm": ItemData(0x0141, ItemType.ENTRANCE_CHEST, ItemClassification.useful), + # 0x0142: "Pearl helmet" + # ----- RING ----- + "Ear jewel": ItemData(0x0143, ItemType.RED_CHEST, ItemClassification.useful), + "Glass brace": ItemData(0x0144, ItemType.RED_CHEST, ItemClassification.useful), + "Glass ring": ItemData(0x0145, ItemType.RED_CHEST, ItemClassification.useful), + "Earring": ItemData(0x4146, ItemType.BLUE_CHEST, ItemClassification.useful), + "Speedy ring": ItemData(0x0147, ItemType.RED_CHEST, ItemClassification.useful), + "Power ring": ItemData(0x0148, ItemType.RED_CHEST, ItemClassification.useful), + "Muscle ring": ItemData(0x0149, ItemType.RED_CHEST, ItemClassification.useful), + "Protect ring": ItemData(0x014A, ItemType.RED_CHEST, ItemClassification.useful), + "Mind ring": ItemData(0x014B, ItemType.RED_CHEST, ItemClassification.useful), + "Witch ring": ItemData(0x014C, ItemType.RED_CHEST, ItemClassification.useful), + "Fire ring": ItemData(0x014D, ItemType.RED_CHEST, ItemClassification.useful), + "Water ring": ItemData(0x014E, ItemType.RED_CHEST, ItemClassification.useful), + "Ice ring": ItemData(0x014F, ItemType.RED_CHEST, ItemClassification.useful), + "Thunder ring": ItemData(0x0150, ItemType.RED_CHEST, ItemClassification.useful), + "Fury ring": ItemData(0x0151, ItemType.RED_CHEST, ItemClassification.useful), + "Mystery ring": ItemData(0x0152, ItemType.RED_CHEST, ItemClassification.useful), + "Sonic ring": ItemData(0x0153, ItemType.RED_CHEST, ItemClassification.useful), + "Hipower ring": ItemData(0x0154, ItemType.RED_CHEST, ItemClassification.useful), + "Trick ring": ItemData(0x0155, ItemType.RED_CHEST, ItemClassification.useful), + "Fake ring": ItemData(0x0156, ItemType.RED_CHEST, ItemClassification.useful), + # 0x0157: "S-fire ring" + # 0x0158: "S-water ring" + # 0x0159: "S-ice ring" + # 0x015A: "S-thun ring" + "S-power ring": ItemData(0x015B, ItemType.RED_CHEST, ItemClassification.useful), + "S-mind ring": ItemData(0x015C, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "S-pro ring": ItemData(0x015D, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "S-witch ring": ItemData(0x015E, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Undead ring": ItemData(0x015F, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Rocket ring": ItemData(0x0160, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Ghost ring": ItemData(0x0161, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Angry ring": ItemData(0x0162, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "S-myst ring": ItemData(0x0163, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Dia ring": ItemData(0x4164, ItemType.BLUE_CHEST, ItemClassification.useful), + "Sea ring": ItemData(0x4165, ItemType.BLUE_CHEST, ItemClassification.useful), + "Dragon ring": ItemData(0x0166, ItemType.ENTRANCE_CHEST, ItemClassification.useful), + "Engage ring": ItemData(0x4167, ItemType.BLUE_CHEST, ItemClassification.useful), + # 0x0168: "Egg ring" + # ----- ROCK ----- + "Horse rock": ItemData(0x0169, ItemType.RED_CHEST, ItemClassification.useful), + "Eagle rock": ItemData(0x016A, ItemType.RED_CHEST, ItemClassification.useful), + "Lion fang": ItemData(0x016B, ItemType.RED_CHEST, ItemClassification.useful), + "Bee rock": ItemData(0x016C, ItemType.RED_CHEST, ItemClassification.useful), + "Snake rock": ItemData(0x016D, ItemType.RED_CHEST, ItemClassification.useful), + "Cancer rock": ItemData(0x016E, ItemType.RED_CHEST, ItemClassification.useful), + "Pumkin jewel": ItemData(0x016F, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Uni jewel": ItemData(0x0170, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Mysto jewel": ItemData(0x0171, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Samu jewel": ItemData(0x0172, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Bat rock": ItemData(0x0173, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Hidora rock": ItemData(0x0174, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Flame jewel": ItemData(0x0175, ItemType.ENEMY_DROP, ItemClassification.useful), + "Water jewel": ItemData(0x4176, ItemType.BLUE_CHEST, ItemClassification.useful), + "Thundo jewel": ItemData(0x4177, ItemType.BLUE_CHEST, ItemClassification.useful), + "Earth jewel": ItemData(0x4178, ItemType.BLUE_CHEST, ItemClassification.useful), + "Twist jewel": ItemData(0x4179, ItemType.BLUE_CHEST, ItemClassification.useful), + "Gloom jewel": ItemData(0x417A, ItemType.BLUE_CHEST, ItemClassification.useful), + "Tidal jewel": ItemData(0x417B, ItemType.BLUE_CHEST, ItemClassification.useful), + "Magma rock": ItemData(0x017C, ItemType.ENEMY_DROP, ItemClassification.useful), + "Evil jewel": ItemData(0x017D, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + # 0x017E: "Evil jewel" (uncursed) + "Gorgon rock": ItemData(0x017F, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Song rock": ItemData(0x0180, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Kraken rock": ItemData(0x0181, ItemType.RED_CHEST_PATCH, ItemClassification.useful), + "Catfish jwl.": ItemData(0x4182, ItemType.BLUE_CHEST, ItemClassification.useful), + "Camu jewel": ItemData(0x4183, ItemType.BLUE_CHEST, ItemClassification.useful), + "Spido jewel": ItemData(0x4184, ItemType.BLUE_CHEST, ItemClassification.useful), + "Gorgan rock": ItemData(0x4185, ItemType.BLUE_CHEST, ItemClassification.useful), + "Light jewel": ItemData(0x0186, ItemType.ENTRANCE_CHEST, ItemClassification.useful), + "Black eye": ItemData(0x4187, ItemType.BLUE_CHEST, ItemClassification.useful), + "Silver eye": ItemData(0x4188, ItemType.BLUE_CHEST, ItemClassification.useful), + "Gold eye": ItemData(0x4189, ItemType.BLUE_CHEST, ItemClassification.useful), + # ----- OTHER ----- + # 0x018A: "1 coin" + # 0x018B: "10 coin set" + # 0x018C: "50 coin set" + # 0x018D: "100 coin set" + # 0x018E: "Flame charm" + # 0x018F: "Zap charm" + # 0x0190: "Magic lamp" + # 0x0191: "Statue" + # 0x0192: "Rage knife" + # 0x0193: "Fortune whip" + # 0x0194: "Dragon blade" + # 0x0195: "Bunny ring" + # 0x0196: "Bunny ears" + # 0x0197: "Bunnylady" + # 0x0198: "Bunny sword" + # 0x0199: "Bunnysuit" + # 0x019A: "Seethru cape" + # 0x019B: "Seethru silk" + # 0x019C: "Iris sword" + # 0x019D: "Iris shield" + # 0x019E: "Iris helmet" + # 0x019F: "Iris armor" + # 0x01A0: "Iris ring" + # 0x01A1: "Iris jewel" + # 0x01A2: "Iris staff" + # 0x01A3: "Iris pot" + # 0x01A4: "Iris tiara" + # 0x01A5: "Power jelly" + # 0x01A6: "Jewel sonar" + # 0x01A7: "Hook" + # 0x01A8: "Bomb" + # 0x01A9: "Arrow" + # 0x01AA: "Fire arrow" + # 0x01AB: "Hammer" + # 0x01AC: "Treas. sword" + # 0x01AD: "Door key" + # 0x01AE: "Shrine key" + # 0x01AF: "Sky key" + # 0x01B0: "Lake key" + # 0x01B1: "Ruby key" + "Selan": ItemData(0x01B2, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Wind key" + "Guy": ItemData(0x01B3, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Cloud key" + "Arty": ItemData(0x01B4, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Light key" + "Dekar": ItemData(0x01B5, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Sword key" + "Tia": ItemData(0x01B6, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Tree key" + "Lexis": ItemData(0x01B7, ItemType.PARTY_MEMBER, ItemClassification.progression), # replaces "Flower key" + "JELZE": ItemData(0x01B8, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Magma key" + "FLASH": ItemData(0x01B9, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Heart key" + "GUSTO": ItemData(0x01BA, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Ghost key" + "ZEPPY": ItemData(0x01BB, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Trial key" + "DARBI": ItemData(0x01BC, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Dankirk key" + "SULLY": ItemData(0x01BD, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Basement key" + "BLAZE": ItemData(0x01BE, ItemType.CAPSULE_MONSTER, ItemClassification.progression), # replaces "Narcysus key" + # 0x01BF: "Truth key" + # 0x01C0: "Mermaid jade" + # 0x01C1: "Engine" + # 0x01C2: "Ancient key" + # 0x01C3: "Pretty flwr." + # 0x01C4: "Glass angel" + # 0x01C5: "VIP card" + # 0x01C6: "Key26" + # 0x01C7: "Key27" + # 0x01C8: "Key28" + # 0x01C9: "Key29" + # 0x01CA: "AP item" # replaces "Key30" + # 0x01CB: "Crown" + # 0x01CC: "Ruby apple" + # 0x01CD: "PURIFIA" + # 0x01CE: "Tag ring" + # 0x01CF: "Tag ring" (uncursed) + # 0x01D0: "RAN-RAN step" + # 0x01D1: "Tag candy" + # 0x01D2: "Last" + # ----- SPELL ----- + "Flash": ItemData(0x8000, ItemType.RED_CHEST, ItemClassification.useful), + "Bolt": ItemData(0x8001, ItemType.RED_CHEST, ItemClassification.useful), + "Thunder": ItemData(0x8002, ItemType.RED_CHEST, ItemClassification.useful), + "Spark": ItemData(0x8003, ItemType.RED_CHEST, ItemClassification.useful), + "Fireball": ItemData(0x8004, ItemType.RED_CHEST, ItemClassification.useful), + "Firebird": ItemData(0x8005, ItemType.RED_CHEST, ItemClassification.useful), + "Droplet": ItemData(0x8006, ItemType.RED_CHEST, ItemClassification.useful), + "Vortex": ItemData(0x8007, ItemType.RED_CHEST, ItemClassification.useful), + "Dragon": ItemData(0x8008, ItemType.RED_CHEST, ItemClassification.useful), + "Gale": ItemData(0x8009, ItemType.RED_CHEST, ItemClassification.useful), + "Blizzard": ItemData(0x800A, ItemType.RED_CHEST, ItemClassification.useful), + "Ice Valk": ItemData(0x800B, ItemType.RED_CHEST, ItemClassification.useful), + "Perish": ItemData(0x800C, ItemType.RED_CHEST, ItemClassification.useful), + "Destroy": ItemData(0x800D, ItemType.RED_CHEST, ItemClassification.useful), + "Drowsy": ItemData(0x800E, ItemType.RED_CHEST, ItemClassification.useful), + "Coma": ItemData(0x800F, ItemType.RED_CHEST, ItemClassification.useful), + "Dread": ItemData(0x8010, ItemType.RED_CHEST, ItemClassification.useful), + "Deflect": ItemData(0x8011, ItemType.RED_CHEST, ItemClassification.useful), + "Absorb": ItemData(0x8012, ItemType.RED_CHEST, ItemClassification.useful), + "Fake": ItemData(0x8013, ItemType.RED_CHEST, ItemClassification.useful), + "Trick": ItemData(0x8014, ItemType.RED_CHEST, ItemClassification.useful), + "Confuse": ItemData(0x8015, ItemType.RED_CHEST, ItemClassification.useful), + "Bravery": ItemData(0x8016, ItemType.RED_CHEST, ItemClassification.useful), + "Courage": ItemData(0x8017, ItemType.RED_CHEST, ItemClassification.useful), + "Mirror": ItemData(0x8018, ItemType.RED_CHEST, ItemClassification.useful), + "Strong": ItemData(0x8019, ItemType.RED_CHEST, ItemClassification.useful), + "Stronger": ItemData(0x801A, ItemType.RED_CHEST, ItemClassification.useful), + "Champion": ItemData(0x801B, ItemType.RED_CHEST, ItemClassification.useful), + "Poison": ItemData(0x801C, ItemType.RED_CHEST, ItemClassification.useful), + "Rally": ItemData(0x801D, ItemType.RED_CHEST, ItemClassification.useful), + "Valor": ItemData(0x801E, ItemType.RED_CHEST, ItemClassification.useful), + "Fry": ItemData(0x801F, ItemType.RED_CHEST, ItemClassification.useful), + "Zap": ItemData(0x8020, ItemType.RED_CHEST, ItemClassification.useful), + "Shield": ItemData(0x8021, ItemType.RED_CHEST, ItemClassification.useful), + "Waken": ItemData(0x8022, ItemType.RED_CHEST, ItemClassification.useful), + # 0x8023: "Release" + # 0x8024: "Warp" + # 0x8025: "Escape" + # 0x8026: "Reset" + # 0x8027: "Light" +} + +l2ac_item_name_to_id: Dict[str, int] = {name: (start_id + data.code) for name, data in l2ac_item_table.items()} diff --git a/worlds/lufia2ac/Locations.py b/worlds/lufia2ac/Locations.py new file mode 100644 index 00000000..7c15bee2 --- /dev/null +++ b/worlds/lufia2ac/Locations.py @@ -0,0 +1,10 @@ +from typing import Dict + +from BaseClasses import Location + +start_id: int = 0xAC0000 +l2ac_location_name_to_id: Dict[str, int] = {f"Blue chest {i + 1}": (start_id + i) for i in range(88)} + + +class L2ACLocation(Location): + game: str = "Lufia II Ancient Cave" diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py new file mode 100644 index 00000000..14ea4f04 --- /dev/null +++ b/worlds/lufia2ac/Options.py @@ -0,0 +1,540 @@ +from __future__ import annotations + +import random +from itertools import chain, combinations +from typing import Any, cast, Dict, List, Optional, Set, Tuple + +from Options import AssembleOptions, Choice, DeathLink, Option, Range, SpecialRange, TextChoice, Toggle + + +class AssembleCustomizableChoices(AssembleOptions): + def __new__(mcs, name: str, bases: Tuple[type, ...], attrs: Dict[str, Any]) -> AssembleCustomizableChoices: + cls: AssembleOptions = super().__new__(mcs, name, bases, attrs) + + if "extra_options" in attrs: + cls.name_lookup.update(enumerate(attrs["extra_options"], start=max(cls.name_lookup) + 1)) + return cast(AssembleCustomizableChoices, cls) + + +class RandomGroupsChoice(Choice, metaclass=AssembleCustomizableChoices): + extra_options: Optional[Set[str]] + random_groups: Dict[str, List[str]] + + @classmethod + def get_option_name(cls, value: int) -> str: + if value in cls.options.values(): + return next(k for k, v in cls.options.items() if v == value) + else: + return super().get_option_name(value) + + @classmethod + def from_text(cls, text: str) -> Choice: + key: str = text.lower() + if key == "random": + text = random.choice([o for o in cls.options if o not in cls.random_groups]) + elif key in cls.random_groups: + text = random.choice(cls.random_groups[key]) + return super().from_text(text) + + +class LevelMixin: + xp_coefficients: List[int] = sorted([191, 65, 50, 32, 18, 14, 6, 3, 3, 2, 2, 2, 2] * 8, reverse=True) + + @classmethod + def _to_xp(cls, level: int, *, capsule: bool) -> int: + if level == 1: + return 0 + if level == 99: + return 9999999 + + increment: int = 20 << 8 + total: int = increment + for lv in range(2, level): + increment += (increment * cls.xp_coefficients[lv]) >> 8 + total += increment + if capsule: + total &= 0xFFFFFF00 + return (total >> 8) - 10 + + +class BlueChestChance(Range): + """The chance of a chest being a blue chest. + + It is given in units of 1/256, i.e., a value of 25 corresponds to 25/256 ~ 9.77%. + If you increase the blue chest chance, then the chance of finding consumables is decreased in return. + The chance of finding red chest equipment or spells is unaffected. + Supported values: 5 – 75 + Default value: 25 (five times as much as in an unmodified game) + """ + + display_name = "Blue chest chance" + range_start = 5 + range_end = 75 + default = 25 + + +class BlueChestCount(Range): + """The number of blue chest items that will be in your item pool. + + The number of blue chests in your world that count as multiworld location checks will be equal this amount plus one + more for each party member or capsule monster if you have shuffle_party_members/shuffle_capsule_monsters enabled. + (You will still encounter blue chests in your world after all the multiworld location checks have been exhausted, + but these chests will then generate items for yourself only.) + Supported values: 10 – 75 + Default value: 25 + """ + + display_name = "Blue chest count" + range_start = 10 + range_end = 75 + default = 25 + + +class Boss(RandomGroupsChoice): + """Which boss to fight on the final floor. + + Supported values: + lizard_man, big_catfish, regal_goblin, follower_x2, camu, tarantula, pierre, daniele, gades_a, mummy_x4, troll_x3, + gades_b, idura_a, lion_x2, idura_b, idura_c, rogue_flower, soldier_x4, gargoyle_x4, venge_ghost, white_dragon_x3, + fire_dragon, ghost_ship, tank, gades_c, amon, erim, daos, egg_dragon, master + random-low — select a random regular boss, from lizard_man to troll_x3 + random-middle — select a random regular boss, from idura_a to gargoyle_x4 + random-high — select a random regular boss, from venge_ghost to tank + random-sinistral — select a random Sinistral boss + Default value: master (same as in an unmodified game) + """ + + display_name = "Boss" + option_lizard_man = 0x01 + option_big_catfish = 0x02 + # 0x03 = Goblin + Skeleton; regular monsters + # 0x04 = Goblin; regular monster + option_regal_goblin = 0x05 + option_follower_x2 = 0x06 + option_camu = 0x07 + option_tarantula = 0x08 + option_pierre = 0x09 + option_daniele = 0x0A + option_gades_a = 0x0B + option_mummy_x4 = 0x0C + option_troll_x3 = 0x0D + option_gades_b = 0x0E + option_idura_a = 0x0F + # 0x10 = Pierre; Maxim + Tia only + # 0x11 = Daniele; Guy + Selan only + option_lion_x2 = 0x12 + option_idura_b = 0x13 + option_idura_c = 0x14 + option_rogue_flower = 0x15 + option_soldier_x4 = 0x16 + option_gargoyle_x4 = 0x17 + option_venge_ghost = 0x18 + option_white_dragon_x3 = 0x19 + option_fire_dragon = 0x1A + option_ghost_ship = 0x1B + # 0x1C = Soldier x4; same as 0x16 + # 0x1D = Soldier x4; same as 0x16 + option_tank = 0x1E + option_gades_c = 0x1F + option_amon = 0x20 + # 0x21 = Gades; same as 0x1F + # 0x22 = Amon; same as 0x20 + option_erim = 0x23 + option_daos = 0x24 + option_egg_dragon = 0x25 + option_master = 0x26 + default = option_master + + random_groups = { + "random-low": ["lizard_man", "big_catfish", "regal_goblin", "follower_x2", "camu", "tarantula", "pierre", + "daniele", "mummy_x4", "troll_x3"], + "random-middle": ["idura_a", "lion_x2", "idura_b", "idura_c", "rogue_flower", "soldier_x4", "gargoyle_x4"], + "random-high": ["venge_ghost", "white_dragon_x3", "fire_dragon", "ghost_ship", "tank"], + "random-sinistral": ["gades_c", "amon", "erim", "daos"], + } + extra_options = frozenset(random_groups) + + @property + def flag(self) -> int: + return 0xFE if self.value == Boss.option_master else 0xFF + + +class CapsuleCravingsJPStyle(Toggle): + """Make capsule monster cravings behave as in the Japanese version. + + In the US version, the data that determines which items a capsule monster can request is a mess. + It allows only for a very limited selection of items to be requested, and the quality of the selected item is almost + always either too low or too high (compared to the capsule monsters current quality preference). This means that, + if fed, the requested item will either be rejected by the capsule monster or lead to an unreasonable increase of the + quality preference, making further feeding more difficult. + This setting provides a fix for the bug described above. + If enabled, the capsule monster feeding behavior will be changed to behave analogous to the JP (and EU) version. + This means that requests become more varied, while the requested item will be guaranteed to be of the same quality + as the capsule monsters current preference. Thus, it can no longer happen that the capsule monster dislikes eating + the very item it just requested. + Supported values: false, true + Default value: false (same as in an unmodified game) + """ + + display_name = "Capsule cravings JP style" + + +class CapsuleStartingForm(SpecialRange): + """The starting form of your capsule monsters. + + Supported values: 1 – 4, m + Default value: 1 (same as in an unmodified game) + """ + + display_name = "Capsule monster starting form" + range_start = 1 + range_end = 5 + default = 1 + special_range_cutoff = 1 + special_range_names = { + "default": 1, + "m": 5, + } + + @property + def unlock(self) -> int: + if self.value == self.special_range_names["m"]: + return 0x0B + else: + return self.value - 1 + + +class CapsuleStartingLevel(LevelMixin, SpecialRange): + """The starting level of your capsule monsters. + + Can be set to the special value party_starting_level to make it the same value as the party_starting_level option. + Supported values: 1 – 99, party_starting_level + Default value: 1 (same as in an unmodified game) + """ + + display_name = "Capsule monster starting level" + range_start = 0 + range_end = 99 + default = 1 + special_range_cutoff = 1 + special_range_names = { + "default": 1, + "party_starting_level": 0, + } + + @property + def xp(self) -> int: + return self._to_xp(self.value, capsule=True) + + +class CrowdedFloorChance(Range): + """The chance of a floor being a crowded floor. + + It is given in units of 1/256, i.e., a value of 16 corresponds to 16/256 = 6.25%. + A crowded floor is a floor where most of the chests are grouped in one room together with many enemies. + Supported values: 0 – 255 + Default value: 16 (same as in an unmodified game) + """ + + display_name = "Crowded floor chance" + range_start = 0 + range_end = 255 + default = 16 + + +class FinalFloor(Range): + """The final floor, where the boss resides. + + Supported values: 2 – 99 + Default value: 99 (same as in an unmodified game) + """ + + display_name = "Final floor" + range_start = 2 + range_end = 99 + default = 99 + + +class GearVarietyAfterB9(Toggle): + """Fixes a bug that prevents various gear from appearing after B9. + + Due to an overflow bug in the game, the distribution of red chest gear is impaired after B9. + Starting with B10, the number of items available from red chests is severely limited, meaning that red chests will + no longer contain any shields, headgear, rings, or jewels (and the selection of body armor is reduced as well). + This setting provides a fix for the bug described above. + If enabled, red chests beyond B9 will continue to produce shields, headgear, rings, and jewels as intended, + while the odds of finding body armor in red chests are decreased as a result. + The distributions of red chest weapons, spells, and consumables as well as blue chests are unaffected. + Supported values: false, true + Default value: false (same as in an unmodified game) + """ + + display_name = "Increase gear variety after B9" + + +class Goal(Choice): + """The objective you have to fulfill in order to complete the game. + + Supported values: + boss — defeat the boss on the final floor + iris_treasure_hunt — gather the required number of Iris treasures and leave the cave + boss_iris_treasure_hunt — complete both the "boss" and the "iris_treasure_hunt" objective (in any order) + final_floor — merely reach the final floor + Default value: boss + """ + + display_name = "Goal" + option_boss = 0x01 + option_iris_treasure_hunt = 0x02 + option_boss_iris_treasure_hunt = 0x03 + option_final_floor = 0x04 + default = option_boss + + +class HealingFloorChance(Range): + """The chance of a floor having a healing tile hidden under a bush. + + It is given in units of 1/256, i.e., a value of 16 corresponds to 16/256 = 6.25%. + Supported values: 0 – 255 + Default value: 16 (same as in an unmodified game) + """ + + display_name = "Healing tile floor chance" + range_start = 0 + range_end = 255 + default = 16 + + +class InitialFloor(Range): + """The initial floor, where you begin your journey. + + (If this value isn't smaller than the value of final_floor, it will automatically be set to final_floor - 1) + Supported values: 1 – 98 + Default value: 1 (same as in an unmodified game) + """ + + display_name = "Initial floor" + range_start = 1 + range_end = 98 + default = 1 + + +class IrisFloorChance(Range): + """The chance of a floor being able to generate an Iris treasure. + + It is given in units of 1/256, i.e., a value of 5 corresponds to 5/256 ~ 1.95%. + The true chance of a floor holding an Iris treasure you need is usually lower than the chance specified here, e.g., + if you have already found 8 of 9 Iris items then the chance of generating the last one is only 1/9 of this value. + Supported values: 5 – 255 + Default value: 5 (same as in an unmodified game) + """ + + display_name = "Iris treasure floor chance" + range_start = 5 + range_end = 255 + default = 5 + + +class IrisTreasuresRequired(Range): + """The number of Iris treasures required to complete the goal. + + This setting only has an effect if the "iris_treasure_hunt" or "boss_iris_treasure_hunt" goal is active. + Supported values: 1 – 9 + Default value: 9 + """ + + display_name = "Iris treasures required" + range_start = 1 + range_end = 9 + default = 9 + + +class MasterHp(SpecialRange): + """The number of hit points of the Master + + Supported values: + 1 – 9980, + scale — scales the HP depending on the value of final_floor + Default value: 9980 (same as in an unmodified game) + """ + + display_name = "Master HP" + range_start = 0 + range_end = 9980 + default = 9980 + special_range_cutoff = 1 + special_range_names = { + "default": 9980, + "scale": 0, + } + + @staticmethod + def scale(final_floor: int) -> int: + return final_floor * 100 + 80 + + +class PartyStartingLevel(LevelMixin, Range): + """The starting level of your party members. + + Supported values: 1 – 99 + Default value: 1 (same as in an unmodified game) + """ + + display_name = "Party starting level" + range_start = 1 + range_end = 99 + default = 1 + + @property + def xp(self) -> int: + return self._to_xp(self.value, capsule=False) + + +class RunSpeed(Choice): + """Modifies the game to allow you to move faster than normal when pressing the Y button. + + Supported values: disabled, double, triple, quadruple + Default value: disabled (same as in an unmodified game) + """ + + display_name = "Run speed" + option_disabled = 0x08 + option_double = 0x10 + option_triple = 0x16 + option_quadruple = 0x20 + default = option_disabled + + +class ShuffleCapsuleMonsters(Toggle): + """Shuffle the capsule monsters into the multiworld. + + Supported values: + false — all 7 capsule monsters are available in the menu and can be selected right away + true — you start without capsule monster; 7 new "items" are added to your pool and shuffled into the multiworld; + when one of these items is found, the corresponding capsule monster is unlocked for you to use + Default value: false (same as in an unmodified game) + """ + + display_name = "Shuffle capsule monsters" + + @property + def unlock(self) -> int: + return 0b00000000 if self.value else 0b01111111 + + +class ShufflePartyMembers(Toggle): + """Shuffle the party members into the multiworld. + + Supported values: + false — all 6 optional party members are present in the cafe and can be recruited right away + true — only Maxim is available from the start; 6 new "items" are added to your pool and shuffled into the + multiworld; when one of these items is found, the corresponding party member is unlocked for you to use + Default value: false (same as in an unmodified game) + """ + + display_name = "Shuffle party members" + + @property + def unlock(self) -> int: + return 0b00000000 if self.value else 0b11111100 + + +class StartingCapsule(Choice): + """The capsule monster you start the game with. + + Only has an effect if shuffle_capsule_monsters is set to false. + Supported values: jelze, flash, gusto, zeppy, darbi, sully, blaze + Default value: jelze + """ + + display_name = "Starting capsule monster" + option_jelze = 0x00 + option_flash = 0x01 + option_gusto = 0x02 + option_zeppy = 0x03 + option_darbi = 0x04 + option_sully = 0x05 + option_blaze = 0x06 + default = option_jelze + + +class StartingParty(RandomGroupsChoice, TextChoice): + """The party you start the game with. + + Only has an effect if shuffle_party_members is set to false. + Supported values: + Can be set to any valid combination of up to 4 party member initials, e.g.: + M — start with Maxim + DGMA — start with Dekar, Guy, Maxim, and Arty + MSTL — start with Maxim, Selan, Tia, and Lexis + random-2p — a random 2-person party + random-3p — a random 3-person party + random-4p — a random 4-person party + Default value: M + """ + + display_name = "Starting party" + default = "M" + + random_groups = { + "random-2p": ["M" + "".join(p) for p in combinations("ADGLST", 1)], + "random-3p": ["M" + "".join(p) for p in combinations("ADGLST", 2)], + "random-4p": ["M" + "".join(p) for p in combinations("ADGLST", 3)], + } + vars().update({f"option_{party}": party for party in (*random_groups, "M", *chain(*random_groups.values()))}) + _valid_sorted_parties: List[List[str]] = [sorted(party) for party in ("M", *chain(*random_groups.values()))] + _members_to_bytes: bytes = bytes.maketrans(b"MSGATDL", bytes(range(7))) + + def verify(self, *args, **kwargs) -> None: + if str(self.value).lower() in self.random_groups: + return + if sorted(str(self.value).upper()) in self._valid_sorted_parties: + return + raise ValueError(f"Could not find option '{self.value}' for '{self.__class__.__name__}', known options are:\n" + f"{', '.join(self.random_groups)}, {', '.join(('M', *chain(*self.random_groups.values())))} " + "as well as all permutations of these.") + + @staticmethod + def _flip(i: int) -> int: + return {4: 5, 5: 4}.get(i, i) + + @property + def event_script(self) -> bytes: + return bytes((*(b for i in bytes(self) if i != 0 for b in (0x2B, i, 0x2E, i + 0x65, 0x1A, self._flip(i) + 1)), + 0x1E, 0x0B, len(self) - 1, 0x1C, 0x86, 0x03, *(0x00,) * (6 * (4 - len(self))))) + + @property + def roster(self) -> bytes: + return bytes((len(self), *bytes(self), *(0xFF,) * (4 - len(self)))) + + def __bytes__(self) -> bytes: + return str(self.value).upper().encode("ASCII").translate(self._members_to_bytes) + + def __len__(self) -> int: + return len(str(self.value)) + + +l2ac_option_definitions: Dict[str, type(Option)] = { + "blue_chest_chance": BlueChestChance, + "blue_chest_count": BlueChestCount, + "boss": Boss, + "capsule_cravings_jp_style": CapsuleCravingsJPStyle, + "capsule_starting_form": CapsuleStartingForm, + "capsule_starting_level": CapsuleStartingLevel, + "crowded_floor_chance": CrowdedFloorChance, + "death_link": DeathLink, + "final_floor": FinalFloor, + "gear_variety_after_b9": GearVarietyAfterB9, + "goal": Goal, + "healing_floor_chance": HealingFloorChance, + "initial_floor": InitialFloor, + "iris_floor_chance": IrisFloorChance, + "iris_treasures_required": IrisTreasuresRequired, + "master_hp": MasterHp, + "party_starting_level": PartyStartingLevel, + "run_speed": RunSpeed, + "shuffle_capsule_monsters": ShuffleCapsuleMonsters, + "shuffle_party_members": ShufflePartyMembers, + "starting_capsule": StartingCapsule, + "starting_party": StartingParty, +} diff --git a/worlds/lufia2ac/Rom.py b/worlds/lufia2ac/Rom.py new file mode 100644 index 00000000..ac168c88 --- /dev/null +++ b/worlds/lufia2ac/Rom.py @@ -0,0 +1,43 @@ +import hashlib +import os +from typing import Optional + +import Utils +from Utils import OptionsType +from worlds.Files import APDeltaPatch + +L2USHASH: str = "6efc477d6203ed2b3b9133c1cd9e9c5d" + + +class L2ACDeltaPatch(APDeltaPatch): + hash = L2USHASH + game = "Lufia II Ancient Cave" + patch_file_ending = ".apl2ac" + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + base_rom_bytes: Optional[bytes] = getattr(get_base_rom_bytes, "base_rom_bytes", None) + if not base_rom_bytes: + file_name: str = get_base_rom_path(file_name) + base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb"))) + + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if L2USHASH != basemd5.hexdigest(): + raise Exception("Supplied Base Rom does not match known MD5 for US release. " + "Get the correct game and version, then dump it") + get_base_rom_bytes.base_rom_bytes = base_rom_bytes + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + options: OptionsType = Utils.get_options() + if not file_name: + file_name = options["lufia2ac_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py new file mode 100644 index 00000000..4b2268ff --- /dev/null +++ b/worlds/lufia2ac/__init__.py @@ -0,0 +1,343 @@ +import base64 +import itertools +import os +from enum import IntFlag +from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple + +from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, RegionType, Tutorial +from Main import __version__ +from Options import AssembleOptions +from worlds.AutoWorld import WebWorld, World +from worlds.generic.Rules import add_rule, set_rule +from .Client import L2ACSNIClient # noqa: F401 +from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id +from .Locations import l2ac_location_name_to_id, L2ACLocation +from .Options import Boss, CapsuleStartingForm, CapsuleStartingLevel, Goal, l2ac_option_definitions, MasterHp, \ + PartyStartingLevel, ShuffleCapsuleMonsters, ShufflePartyMembers, StartingParty +from .Rom import get_base_rom_bytes, get_base_rom_path, L2ACDeltaPatch +from .basepatch import apply_basepatch + +CHESTS_PER_SPHERE: int = 5 + + +class L2ACWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up Lufia II Ancient Cave for MultiWorld.", + "English", + "setup_en.md", + "setup/en", + ["word_fcuk"] + )] + theme = "dirt" + + +class L2ACWorld(World): + """ + The Ancient Cave is a roguelike dungeon crawling game built into + the RGP Lufia II. Face 99 floors of ever harder to beat monsters, + random items and find new companions on the way to face the Royal + Jelly in the end. Can you beat it? + """ + game: ClassVar[str] = "Lufia II Ancient Cave" + web: ClassVar[WebWorld] = L2ACWeb() + + option_definitions: ClassVar[Dict[str, AssembleOptions]] = l2ac_option_definitions + item_name_to_id: ClassVar[Dict[str, int]] = l2ac_item_name_to_id + location_name_to_id: ClassVar[Dict[str, int]] = l2ac_location_name_to_id + item_name_groups: ClassVar[Dict[str, Set[str]]] = { + "Blue chest items": {name for name, data in l2ac_item_table.items() if data.type is ItemType.BLUE_CHEST}, + "Capsule monsters": {name for name, data in l2ac_item_table.items() if data.type is ItemType.CAPSULE_MONSTER}, + "Party members": {name for name, data in l2ac_item_table.items() if data.type is ItemType.PARTY_MEMBER}, + } + data_version: ClassVar[int] = 1 + required_client_version: Tuple[int, int, int] = (0, 3, 6) + + # L2ACWorld specific properties + rom_name: Optional[bytearray] + + blue_chest_chance: Optional[int] + blue_chest_count: Optional[int] + boss: Optional[Boss] + capsule_cravings_jp_style: Optional[int] + capsule_starting_form: Optional[CapsuleStartingForm] + capsule_starting_level: Optional[CapsuleStartingLevel] + crowded_floor_chance: Optional[int] + death_link: Optional[int] + final_floor: Optional[int] + gear_variety_after_b9: Optional[int] + goal: Optional[int] + healing_floor_chance: Optional[int] + initial_floor: Optional[int] + iris_floor_chance: Optional[int] + iris_treasures_required: Optional[int] + master_hp: Optional[int] + party_starting_level: Optional[PartyStartingLevel] + run_speed: Optional[int] + shuffle_capsule_monsters: Optional[ShuffleCapsuleMonsters] + shuffle_party_members: Optional[ShufflePartyMembers] + starting_capsule: Optional[int] + starting_party: Optional[StartingParty] + + @classmethod + def stage_assert_generate(cls, _multiworld: MultiWorld) -> None: + rom_file: str = get_base_rom_path() + if not os.path.exists(rom_file): + raise FileNotFoundError(f"Could not find base ROM for {cls.game}: {rom_file}") + + # # uncomment this section to recreate the basepatch + # # (you will need to provide "asar.py" as well as an Asar library in the basepatch directory) + # from .basepatch import create_basepatch + # create_basepatch() + + def generate_early(self) -> None: + self.rom_name = \ + bytearray(f"L2AC{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21] + self.rom_name.extend([0] * (21 - len(self.rom_name))) + + self.blue_chest_chance = self.multiworld.blue_chest_chance[self.player].value + self.blue_chest_count = self.multiworld.blue_chest_count[self.player].value + self.boss = self.multiworld.boss[self.player] + self.capsule_cravings_jp_style = self.multiworld.capsule_cravings_jp_style[self.player].value + self.capsule_starting_form = self.multiworld.capsule_starting_form[self.player] + self.capsule_starting_level = self.multiworld.capsule_starting_level[self.player] + self.crowded_floor_chance = self.multiworld.crowded_floor_chance[self.player].value + self.death_link = self.multiworld.death_link[self.player].value + self.final_floor = self.multiworld.final_floor[self.player].value + self.gear_variety_after_b9 = self.multiworld.gear_variety_after_b9[self.player].value + self.goal = self.multiworld.goal[self.player].value + self.healing_floor_chance = self.multiworld.healing_floor_chance[self.player].value + self.initial_floor = self.multiworld.initial_floor[self.player].value + self.iris_floor_chance = self.multiworld.iris_floor_chance[self.player].value + self.iris_treasures_required = self.multiworld.iris_treasures_required[self.player].value + self.master_hp = self.multiworld.master_hp[self.player].value + self.party_starting_level = self.multiworld.party_starting_level[self.player] + self.run_speed = self.multiworld.run_speed[self.player].value + self.shuffle_capsule_monsters = self.multiworld.shuffle_capsule_monsters[self.player] + self.shuffle_party_members = self.multiworld.shuffle_party_members[self.player] + self.starting_capsule = self.multiworld.starting_capsule[self.player].value + self.starting_party = self.multiworld.starting_party[self.player] + + if self.capsule_starting_level.value == CapsuleStartingLevel.special_range_names["party_starting_level"]: + self.capsule_starting_level.value = self.party_starting_level.value + if self.initial_floor >= self.final_floor: + self.initial_floor = self.final_floor - 1 + if self.master_hp == MasterHp.special_range_names["scale"]: + self.master_hp = MasterHp.scale(self.final_floor) + if self.shuffle_party_members: + self.starting_party.value = StartingParty.default + + def create_regions(self) -> None: + menu = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld) + menu.exits.append(Entrance(self.player, "AncientDungeonEntrance", menu)) + self.multiworld.regions.append(menu) + + ancient_dungeon = Region("AncientDungeon", RegionType.Generic, "Ancient Dungeon", self.player, self.multiworld) + ancient_dungeon.exits.append(Entrance(self.player, "FinalFloorEntrance", menu)) + item_count: int = self.blue_chest_count + if self.shuffle_capsule_monsters: + item_count += len(self.item_name_groups["Capsule monsters"]) + if self.shuffle_party_members: + item_count += len(self.item_name_groups["Party members"]) + for location_name, location_id in itertools.islice(l2ac_location_name_to_id.items(), item_count): + ancient_dungeon.locations.append(L2ACLocation(self.player, location_name, location_id, ancient_dungeon)) + prog_chest_access = L2ACItem("Progressive chest access", ItemClassification.progression, None, self.player) + for i in range(CHESTS_PER_SPHERE, item_count, CHESTS_PER_SPHERE): + chest_access = \ + L2ACLocation(self.player, f"Chest access {i + 1}-{i + CHESTS_PER_SPHERE}", None, ancient_dungeon) + chest_access.place_locked_item(prog_chest_access) + ancient_dungeon.locations.append(chest_access) + treasures = L2ACLocation(self.player, "Iris Treasures", None, ancient_dungeon) + treasures.place_locked_item(L2ACItem("Treasures collected", ItemClassification.progression, None, self.player)) + ancient_dungeon.locations.append(treasures) + self.multiworld.regions.append(ancient_dungeon) + + final_floor = Region("FinalFloor", RegionType.Generic, "Ancient Cave Final Floor", self.player, self.multiworld) + ff_reached = L2ACLocation(self.player, "Final Floor reached", None, final_floor) + ff_reached.place_locked_item(L2ACItem("Final Floor access", ItemClassification.progression, None, self.player)) + final_floor.locations.append(ff_reached) + boss = L2ACLocation(self.player, "Boss", None, final_floor) + boss.place_locked_item(L2ACItem("Boss victory", ItemClassification.progression, None, self.player)) + final_floor.locations.append(boss) + self.multiworld.regions.append(final_floor) + + self.multiworld.get_entrance("AncientDungeonEntrance", self.player) \ + .connect(self.multiworld.get_region("AncientDungeon", self.player)) + self.multiworld.get_entrance("FinalFloorEntrance", self.player) \ + .connect(self.multiworld.get_region("FinalFloor", self.player)) + + def create_items(self) -> None: + item_pool: List[str] = \ + self.multiworld.random.choices(sorted(self.item_name_groups["Blue chest items"]), k=self.blue_chest_count) + if self.shuffle_capsule_monsters: + item_pool += self.item_name_groups["Capsule monsters"] + self.blue_chest_count += len(self.item_name_groups["Capsule monsters"]) + if self.shuffle_party_members: + item_pool += self.item_name_groups["Party members"] + self.blue_chest_count += len(self.item_name_groups["Party members"]) + for item_name in item_pool: + item_data: ItemData = l2ac_item_table[item_name] + item_id: int = items_start_id + item_data.code + self.multiworld.itempool.append(L2ACItem(item_name, item_data.classification, item_id, self.player)) + + def set_rules(self) -> None: + for i in range(1, self.blue_chest_count): + if i % CHESTS_PER_SPHERE == 0: + set_rule(self.multiworld.get_location(f"Blue chest {i + 1}", self.player), + lambda state, j=i: state.has("Progressive chest access", self.player, j // CHESTS_PER_SPHERE)) + set_rule(self.multiworld.get_location(f"Chest access {i + 1}-{i + CHESTS_PER_SPHERE}", self.player), + lambda state, j=i: state.can_reach(f"Blue chest {j}", "Location", self.player)) + else: + set_rule(self.multiworld.get_location(f"Blue chest {i + 1}", self.player), + lambda state, j=i: state.can_reach(f"Blue chest {j}", "Location", self.player)) + + set_rule(self.multiworld.get_entrance("FinalFloorEntrance", self.player), + lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) + set_rule(self.multiworld.get_location("Iris Treasures", self.player), + lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) + set_rule(self.multiworld.get_location("Boss", self.player), + lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) + if self.shuffle_capsule_monsters: + add_rule(self.multiworld.get_location("Boss", self.player), lambda state: state.has("DARBI", self.player)) + if self.shuffle_party_members: + add_rule(self.multiworld.get_location("Boss", self.player), lambda state: state.has("Dekar", self.player) + and state.has("Guy", self.player) and state.has("Arty", self.player)) + + if self.goal == Goal.option_final_floor: + self.multiworld.completion_condition[self.player] = \ + lambda state: state.has("Final Floor access", self.player) + elif self.goal == Goal.option_iris_treasure_hunt: + self.multiworld.completion_condition[self.player] = \ + lambda state: state.has("Treasures collected", self.player) + elif self.goal == Goal.option_boss: + self.multiworld.completion_condition[self.player] = \ + lambda state: state.has("Boss victory", self.player) + elif self.goal == Goal.option_boss_iris_treasure_hunt: + self.multiworld.completion_condition[self.player] = \ + lambda state: state.has("Boss victory", self.player) and state.has("Treasures collected", self.player) + + def generate_output(self, output_directory: str) -> None: + rom_path: str = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.sfc") + + try: + rom_bytearray = bytearray(apply_basepatch(get_base_rom_bytes())) + # start and stop indices are offsets in the ROM file, not LoROM mapped SNES addresses + rom_bytearray[0x007FC0:0x007FC0 + 21] = self.rom_name + rom_bytearray[0x014308:0x014308 + 1] = self.capsule_starting_level.value.to_bytes(1, "little") + rom_bytearray[0x01432F:0x01432F + 1] = self.capsule_starting_form.unlock.to_bytes(1, "little") + rom_bytearray[0x01433C:0x01433C + 1] = self.capsule_starting_form.value.to_bytes(1, "little") + rom_bytearray[0x0190D5:0x0190D5 + 1] = self.iris_floor_chance.to_bytes(1, "little") + rom_bytearray[0x019153:0x019153 + 1] = (0x63 - self.blue_chest_chance).to_bytes(1, "little") + rom_bytearray[0x019176] = 0x38 if self.gear_variety_after_b9 else 0x18 + rom_bytearray[0x019477:0x019477 + 1] = self.healing_floor_chance.to_bytes(1, "little") + rom_bytearray[0x0194A2:0x0194A2 + 1] = self.crowded_floor_chance.to_bytes(1, "little") + rom_bytearray[0x019E82:0x019E82 + 1] = self.final_floor.to_bytes(1, "little") + rom_bytearray[0x01FC75:0x01FC75 + 1] = self.run_speed.to_bytes(1, "little") + rom_bytearray[0x01FC81:0x01FC81 + 1] = self.run_speed.to_bytes(1, "little") + rom_bytearray[0x02B2A1:0x02B2A1 + 5] = self.starting_party.roster + for offset in range(0x02B395, 0x02B452, 0x1B): + rom_bytearray[offset:offset + 1] = self.party_starting_level.value.to_bytes(1, "little") + for offset in range(0x02B39A, 0x02B457, 0x1B): + rom_bytearray[offset:offset + 3] = self.party_starting_level.xp.to_bytes(3, "little") + rom_bytearray[0x05699E:0x05699E + 147] = self.get_goal_text_bytes() + rom_bytearray[0x056AA3:0x056AA3 + 24] = self.starting_party.event_script + rom_bytearray[0x072742:0x072742 + 1] = self.boss.value.to_bytes(1, "little") + rom_bytearray[0x072748:0x072748 + 1] = self.boss.flag.to_bytes(1, "little") + rom_bytearray[0x09D59B:0x09D59B + 256] = self.get_node_connection_table() + rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.master_hp.to_bytes(2, "little") + rom_bytearray[0x280010:0x280010 + 2] = self.blue_chest_count.to_bytes(2, "little") + rom_bytearray[0x280012:0x280012 + 3] = self.capsule_starting_level.xp.to_bytes(3, "little") + rom_bytearray[0x280015:0x280015 + 1] = self.initial_floor.to_bytes(1, "little") + rom_bytearray[0x280016:0x280016 + 1] = self.starting_capsule.to_bytes(1, "little") + rom_bytearray[0x280017:0x280017 + 1] = self.iris_treasures_required.to_bytes(1, "little") + rom_bytearray[0x280018:0x280018 + 1] = self.shuffle_party_members.unlock.to_bytes(1, "little") + rom_bytearray[0x280019:0x280019 + 1] = self.shuffle_capsule_monsters.unlock.to_bytes(1, "little") + rom_bytearray[0x280030:0x280030 + 1] = self.goal.to_bytes(1, "little") + rom_bytearray[0x28003D:0x28003D + 1] = self.death_link.to_bytes(1, "little") + rom_bytearray[0x281200:0x281200 + 470] = self.get_capsule_cravings_table() + + with open(rom_path, "wb") as f: + f.write(rom_bytearray) + except Exception as e: + raise e + else: + patch = L2ACDeltaPatch(os.path.splitext(rom_path)[0] + L2ACDeltaPatch.patch_file_ending, + player=self.player, player_name=self.multiworld.player_name[self.player], + patched_path=rom_path) + patch.write() + finally: + if os.path.exists(rom_path): + os.unlink(rom_path) + + def modify_multidata(self, multidata: Dict[str, Any]) -> None: + b64_name: str = base64.b64encode(bytes(self.rom_name)).decode() + multidata["connect_names"][b64_name] = multidata["connect_names"][self.multiworld.player_name[self.player]] + + # end of ordered Main.py calls + + def create_item(self, name: str) -> Item: + item_data: ItemData = l2ac_item_table.get(name) + return L2ACItem(name, item_data.classification, items_start_id + item_data.code, self.player) + + def get_capsule_cravings_table(self) -> bytes: + rom: bytes = get_base_rom_bytes() + + if self.capsule_cravings_jp_style: + number_of_items: int = 467 + items_offset: int = 0x0B4F69 + value_thresholds: List[int] = \ + [200, 500, 600, 800, 1000, 2000, 3000, 4000, 5000, 6000, 8000, 12000, 20000, 25000, 29000, 32000, 33000] + tier_list: List[List[int]] = [list() for _ in value_thresholds[:-1]] + + for item_id in range(number_of_items): + pointer: int = int.from_bytes(rom[items_offset + 2 * item_id:items_offset + 2 * item_id + 2], "little") + if rom[items_offset + pointer] & 0x20 == 0 and rom[items_offset + pointer + 1] & 0x40 == 0: + value: int = int.from_bytes(rom[items_offset + pointer + 5:items_offset + pointer + 7], "little") + for t in range(len(tier_list)): + if value_thresholds[t] <= value < value_thresholds[t + 1]: + tier_list[t].append(item_id) + break + tier_sizes: List[int] = [len(tier) for tier in tier_list] + + cravings_table: bytes = b"".join(i.to_bytes(2, "little") for i in itertools.chain( + *zip(itertools.accumulate((2 * tier_size for tier_size in tier_sizes), initial=0x40), tier_sizes), + (item_id for tier in tier_list for item_id in tier))) + assert len(cravings_table) == 470, cravings_table + return cravings_table + else: + return rom[0x0AFF16:0x0AFF16 + 470] + + def get_goal_text_bytes(self) -> bytes: + goal_text: List[str] = [] + iris: str = f"{self.iris_treasures_required} Iris treasure{'s' if self.iris_treasures_required > 1 else ''}" + if self.goal == Goal.option_boss: + goal_text = ["You have to defeat", f"the boss on B{self.final_floor}."] + elif self.goal == Goal.option_iris_treasure_hunt: + goal_text = ["You have to find", f"{iris}."] + elif self.goal == Goal.option_boss_iris_treasure_hunt: + goal_text = ["You have to retrieve", f"{iris} and", f"defeat the boss on B{self.final_floor}."] + elif self.goal == Goal.option_final_floor: + goal_text = [f"You need to get to B{self.final_floor}."] + assert len(goal_text) <= 4 and all(len(line) <= 28 for line in goal_text), goal_text + goal_text_bytes = bytes((0x08, *b"\x03".join(line.encode("ascii") for line in goal_text), 0x00)) + return goal_text_bytes + b"\x00" * (147 - len(goal_text_bytes)) + + @staticmethod + def get_node_connection_table() -> bytes: + class Connect(IntFlag): + TOP_LEFT = 0b00000001 + LEFT = 0b00000010 + BOTTOM_LEFT = 0b00000100 + TOP = 0b00001000 + BOTTOM = 0b00010000 + TOP_RIGHT = 0b00100000 + RIGHT = 0b01000000 + BOTTOM_RIGHT = 0b10000000 + + rom: bytes = get_base_rom_bytes() + + return bytes(rom[0x09D59B + ((n & ~Connect.TOP_LEFT if not n & (Connect.TOP | Connect.LEFT) else n) & + (n & ~Connect.TOP_RIGHT if not n & (Connect.TOP | Connect.RIGHT) else n) & + (n & ~Connect.BOTTOM_LEFT if not n & (Connect.BOTTOM | Connect.LEFT) else n) & + (n & ~Connect.BOTTOM_RIGHT if not n & (Connect.BOTTOM | Connect.RIGHT) else n))] + for n in range(256)) diff --git a/worlds/lufia2ac/basepatch/.gitignore b/worlds/lufia2ac/basepatch/.gitignore new file mode 100644 index 00000000..b9abdda5 --- /dev/null +++ b/worlds/lufia2ac/basepatch/.gitignore @@ -0,0 +1,2 @@ +asar* +libasar* diff --git a/worlds/lufia2ac/basepatch/__init__.py b/worlds/lufia2ac/basepatch/__init__.py new file mode 100644 index 00000000..d09a344d --- /dev/null +++ b/worlds/lufia2ac/basepatch/__init__.py @@ -0,0 +1,47 @@ +import os + +import bsdiff4 + +from ..Rom import get_base_rom_bytes + + +def apply_basepatch(base_rom_bytes: bytes) -> bytes: + with open(os.path.join(os.path.dirname(__file__), "basepatch.bsdiff4"), "rb") as basepatch: + delta: bytes = basepatch.read() + return bsdiff4.patch(base_rom_bytes, delta) + + +def create_basepatch() -> None: + from .asar import close as asar_close, geterrors as asar_errors, getprints as asar_prints, \ + getwarnings as asar_warnings, init as asar_init, patch as asar_patch + + os.add_dll_directory(os.path.dirname(__file__)) + print("Initializing Asar library") + asar_init() + + print("Opening base ROM") + old_rom_data: bytes = get_base_rom_bytes() + + print("Patching base ROM") + result, new_rom_data = asar_patch(os.path.join(os.path.dirname(__file__), "basepatch.asm"), old_rom_data) + + warnings = asar_warnings() + print("\nWarnings: " + str(len(warnings))) + for w in warnings: + print(w) + + if result: + print("Success") + for p in asar_prints(): + print(p) + asar_close() + delta: bytes = bsdiff4.diff(old_rom_data, new_rom_data) + with open(os.path.join(os.path.dirname(__file__), "basepatch.bsdiff4"), "wb") as f: + f.write(delta) + else: + errors = asar_errors() + print("\nErrors: " + str(len(errors))) + for error in errors: + print(error) + asar_close() + raise RuntimeError("Asar errors while trying to create basepatch for Lufia II Ancient Cave.") diff --git a/worlds/lufia2ac/basepatch/ap_logo/LICENSE b/worlds/lufia2ac/basepatch/ap_logo/LICENSE new file mode 100644 index 00000000..66c90cee --- /dev/null +++ b/worlds/lufia2ac/basepatch/ap_logo/LICENSE @@ -0,0 +1,6 @@ +This work is licensed under Attribution-NonCommercial 4.0 International. +To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/ + +It is based on the colored AP icon. +That icon © 2022 by Krista Corkos and Christopher Wilson is licensed under Attribution-NonCommercial 4.0 International. +To view a copy of that license, visit http://creativecommons.org/licenses/by-nc/4.0/ diff --git a/worlds/lufia2ac/basepatch/ap_logo/ap_logo.bin b/worlds/lufia2ac/basepatch/ap_logo/ap_logo.bin new file mode 100644 index 00000000..8b825ec7 --- /dev/null +++ b/worlds/lufia2ac/basepatch/ap_logo/ap_logo.bin @@ -0,0 +1 @@ +&?CB~<<?>~~<<€€@À à8ødäÂÂBB<<€€À@à ø8ä|ÂþB~<<<<~~?>$ Cores --> SNES --> BSNES` + Once you have changed the loaded core, you must restart BizHawk. +2. Load your ROM file if it hasn't already been loaded. +3. Click on the Tools menu and click on **Lua Console** +4. Click Script -> Open Script... +5. Select the `Connector.lua` file you downloaded above + - Look in the Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the + emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only. + +##### RetroArch 1.10.3 or newer + +You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.3. + +1. Enter the RetroArch main menu screen. +2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. +3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default + Network Command Port at 55355. + +![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png) +4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury + Performance)". + +When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to +read ROM data. + +#### With hardware + +This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do +this now. SD2SNES and FXPak Pro users may download the appropriate firmware on the SD2SNES releases page. SD2SNES +releases page: [SD2SNES Releases Page](https://github.com/RedGuyyyy/sd2snes/releases) + +Other hardware may find helpful information on the usb2snes platforms +page: [usb2snes Supported Platforms Page](http://usb2snes.com/#supported-platforms) + +1. Close your emulator, which may have auto-launched. +2. Power on your device and load the ROM. + +### Connect to the Archipelago Server + +The patch file which launched your client should have automatically connected you to the AP Server. There are a few +reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the +client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it +into the "Server" input field then press enter. + +The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected". + +### Play the game + +When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations on +successfully joining a multiworld game! You can execute various commands in your client. For more information regarding +these commands you can use `/help` for local client commands and `!help` for server commands. diff --git a/worlds/lufia2ac/test/TestGoal.py b/worlds/lufia2ac/test/TestGoal.py new file mode 100644 index 00000000..06393ff1 --- /dev/null +++ b/worlds/lufia2ac/test/TestGoal.py @@ -0,0 +1,87 @@ +from . import L2ACTestBase + + +class TestDefault(L2ACTestBase): + options = {} + + def testEverything(self): + self.collect_all_but(["Boss victory"]) + self.assertBeatable(True) + + def testNothing(self): + self.assertBeatable(True) + + +class TestShuffleCapsuleMonsters(L2ACTestBase): + options = { + "shuffle_capsule_monsters": True, + } + + def testEverything(self): + self.collect_all_but(["Boss victory"]) + self.assertBeatable(True) + + def testBestParty(self): + self.collect_by_name("DARBI") + self.assertBeatable(True) + + def testNoDarbi(self): + self.collect_all_but(["Boss victory", "DARBI"]) + self.assertBeatable(False) + + +class TestShufflePartyMembers(L2ACTestBase): + options = { + "shuffle_party_members": True, + } + + def testEverything(self): + self.collect_all_but(["Boss victory"]) + self.assertBeatable(True) + + def testBestParty(self): + self.collect_by_name(["Dekar", "Guy", "Arty"]) + self.assertBeatable(True) + + def testNoDekar(self): + self.collect_all_but(["Boss victory", "Dekar"]) + self.assertBeatable(False) + + def testNoGuy(self): + self.collect_all_but(["Boss victory", "Guy"]) + self.assertBeatable(False) + + def testNoArty(self): + self.collect_all_but(["Boss victory", "Arty"]) + self.assertBeatable(False) + + +class TestShuffleBoth(L2ACTestBase): + options = { + "shuffle_capsule_monsters": True, + "shuffle_party_members": True, + } + + def testEverything(self): + self.collect_all_but(["Boss victory"]) + self.assertBeatable(True) + + def testBestParty(self): + self.collect_by_name(["Dekar", "Guy", "Arty", "DARBI"]) + self.assertBeatable(True) + + def testNoDekar(self): + self.collect_all_but(["Boss victory", "Dekar"]) + self.assertBeatable(False) + + def testNoGuy(self): + self.collect_all_but(["Boss victory", "Guy"]) + self.assertBeatable(False) + + def testNoArty(self): + self.collect_all_but(["Boss victory", "Arty"]) + self.assertBeatable(False) + + def testNoDarbi(self): + self.collect_all_but(["Boss victory", "DARBI"]) + self.assertBeatable(False) diff --git a/worlds/lufia2ac/test/__init__.py b/worlds/lufia2ac/test/__init__.py new file mode 100644 index 00000000..24925675 --- /dev/null +++ b/worlds/lufia2ac/test/__init__.py @@ -0,0 +1,5 @@ +from test.TestBase import WorldTestBase + + +class L2ACTestBase(WorldTestBase): + game = "Lufia II Ancient Cave"