diff --git a/SNIClient.py b/SNIClient.py index 8d402b1d..f4ad53c6 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -115,8 +115,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor): class SNIContext(CommonContext): command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor - game = None # set in validate_rom - items_handling = None # set in game_watcher + game: typing.Optional[str] = None # set in validate_rom + items_handling: typing.Optional[int] = None # set in game_watcher snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None diff --git a/docs/network diagram/network diagram.jpg b/docs/network diagram/network diagram.jpg index 15495e27..0027db57 100644 Binary files a/docs/network diagram/network diagram.jpg and b/docs/network diagram/network diagram.jpg differ diff --git a/docs/network diagram/network diagram.md b/docs/network diagram/network diagram.md index 2bffd9f2..926c8723 100644 --- a/docs/network diagram/network diagram.md +++ b/docs/network diagram/network diagram.md @@ -75,6 +75,18 @@ flowchart LR end SNI <-- Various, depending on SNES device --> DK3 + %% Super Mario World + subgraph Super Mario World + SMW[SNES] + end + SNI <-- Various, depending on SNES device --> SMW + + %% Lufia II Ancient Cave + subgraph Lufia II Ancient Cave + L2AC[SNES] + end + SNI <-- Various, depending on SNES device --> L2AC + %% Native Clients or Games %% Games or clients which compile to native or which the client is integrated in the game. subgraph "Native" diff --git a/docs/network diagram/network diagram.svg b/docs/network diagram/network diagram.svg index 38d3cc07..ba29b744 100644 --- a/docs/network diagram/network diagram.svg +++ b/docs/network diagram/network diagram.svg @@ -1 +1 @@ -FactorioSecret of EvermoreWebHost (archipelago.gg).NETJavaNativeDonkey Kong Country 3Super Metroid/A Link to the Past Combo RandomizerSuper MetroidOcarina of TimeFinal Fantasy 1A Link to the PastChecksFinderStarcraft 2FNA/XNAUnityMinecraftSecret of EvermoreWebSocketsWebSocketsIntegratedIntegratedVarious, depending on SNES deviceLuaSocketsIntegratedLuaSocketsIntegratedIntegratedWebSocketsVarious, depending on SNES deviceVarious, depending on SNES deviceVarious, depending on SNES deviceThe Witness RandomizerVarious, depending on SNES deviceWebSocketsWebSocketsMod the SpireTCPForge Mod LoaderWebSocketsTsRandomizerRogueLegacyRandomizerBepInExQModLoader (BepInEx)HK Modding APIWebSocketsSQLSubprocessesSQLDeposit Generated WorldsProvide Generation InstructionsSubprocessesSubprocessesRCONUDPIntegratedFactorio ServerFactorioClientFactorio GamesFactorio Mod Generated by APFactorio Modding APISNESConfigurable (waitress, gunicorn, flask)AutoHosterPonyORM DBWebHostFlask WebContentAutoGeneratorMod with Archipelago.MultiClient.NetRisk of Rain 2SubnauticaHollow KnightRaftTimespinnerRogue LegacyMod with Archipelago.MultiClient.JavaSlay the SpireMinecraft Forge ServerAny Java Minecraft ClientsGame using apclientpp Client LibraryGame using Apcpp Client LibrarySuper Mario 64 ExVVVVVVMeritousThe WitnessSonic Adventure 2: BattleDark Souls 3ap-soeclientSNESSNESSNESOoTClientLua ConnectorBizHawk with Ocarina of Time LoadedFF1ClientLua ConnectorBizHawk with Final Fantasy LoadedSNESChecksFinderClientChecksFinderStarcraft 2 Game ClientStarcraft2Client.pyapsc2 Python PackageArchipelago ServerCommonClient.pySuper Nintendo Interface (SNI)SNIClient \ No newline at end of file +FactorioSecret of EvermoreWebHost (archipelago.gg).NETJavaNativeLufia II Ancient CaveSuper Mario WorldDonkey Kong Country 3SMZ3Super MetroidOcarina of TimeFinal Fantasy 1A Link to the PastChecksFinderStarcraft 2FNA/XNAUnityMinecraftSecret of EvermoreWebSocketsWebSocketsIntegratedIntegratedVarious, depending on SNES deviceLuaSocketsIntegratedLuaSocketsIntegratedIntegratedWebSocketsVarious, depending on SNES deviceVarious, depending on SNES deviceVarious, depending on SNES deviceVarious, depending on SNES deviceVarious, depending on SNES deviceThe Witness RandomizerVarious, depending on SNES deviceWebSocketsWebSocketsMod the SpireTCPForge Mod LoaderWebSocketsTsRandomizerRogueLegacyRandomizerBepInExQModLoader (BepInEx)HK Modding APIWebSocketsSQLSubprocessesSQLDeposit Generated WorldsProvide Generation InstructionsSubprocessesSubprocessesRCONUDPIntegratedFactorio ServerFactorioClientFactorio GamesFactorio Mod Generated by APFactorio Modding APISNESConfigurable (waitress, gunicorn, flask)AutoHosterPonyORM DBWebHostFlask WebContentAutoGeneratorMod with Archipelago.MultiClient.NetRisk of Rain 2SubnauticaHollow KnightRaftTimespinnerRogue LegacyMod with Archipelago.MultiClient.JavaSlay the SpireMinecraft Forge ServerAny Java Minecraft ClientsGame using apclientpp Client LibraryGame using Apcpp Client LibrarySuper Mario 64 ExVVVVVVMeritousThe WitnessSonic Adventure 2: BattleDark Souls 3ap-soeclientSNESSNESSNESSNESSNESOoTClientLua ConnectorBizHawk with Ocarina of Time LoadedFF1ClientLua ConnectorBizHawk with Final Fantasy LoadedSNESChecksFinderClientChecksFinderStarcraft 2 Game ClientStarcraft2Client.pyapsc2 Python PackageArchipelago ServerCommonClient.pySuper Nintendo Interface (SNI)SNIClient \ No newline at end of file diff --git a/worlds/lufia2ac/Client.py b/worlds/lufia2ac/Client.py index 002b257d..58ee7f87 100644 --- a/worlds/lufia2ac/Client.py +++ b/worlds/lufia2ac/Client.py @@ -2,10 +2,11 @@ import logging import time import typing from logging import Logger -from typing import Dict +from typing import Optional from NetUtils import ClientStatus, NetworkItem from worlds.AutoSNIClient import SNIClient +from .Enemies import enemy_id_to_name from .Items import start_id as items_start_id from .Locations import start_id as locations_start_id @@ -24,233 +25,6 @@ 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" @@ -258,7 +32,7 @@ class L2ACSNIClient(SNIClient): async def validate_rom(self, ctx: SNIContext) -> bool: from SNIClient import snes_read - rom_name: bytes = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) + rom_name: Optional[bytes] = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) if rom_name is None or rom_name[:4] != b"L2AC": return False @@ -272,7 +46,7 @@ class L2ACSNIClient(SNIClient): 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) + rom: Optional[bytes] = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) if rom != ctx.rom: ctx.rom = None return @@ -281,30 +55,30 @@ class L2ACSNIClient(SNIClient): # not successfully connected to a multiworld server, cannot process the game sending items return - signature: bytes = await snes_read(ctx, L2AC_SIGN_ADDR, 16) + signature: Optional[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) + goal_data: Optional[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) + death_data: Optional[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)) + enemy_name: str = enemy_id_to_name.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) + tx_data: Optional[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") @@ -316,7 +90,7 @@ class L2ACSNIClient(SNIClient): client_items_sent += 1 ctx.locations_checked.add(location_id) - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": [location_id]}]) snes_logger.info("New Check: %s (%d/%d)" % ( location, @@ -329,7 +103,7 @@ class L2ACSNIClient(SNIClient): 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) + rx_data: Optional[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") @@ -343,7 +117,7 @@ class L2ACSNIClient(SNIClient): 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 + 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) @@ -352,7 +126,7 @@ class L2ACSNIClient(SNIClient): 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(): + if "DeathLink" in ctx.tags: snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x01") else: snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x00") diff --git a/worlds/lufia2ac/Enemies.py b/worlds/lufia2ac/Enemies.py new file mode 100644 index 00000000..5d82966c --- /dev/null +++ b/worlds/lufia2ac/Enemies.py @@ -0,0 +1,382 @@ +from typing import Dict + +enemy_id_to_name: 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", +} + +enemy_name_to_sprite: Dict[str, int] = { + "Ammonite": 0x81, + "Antares": 0x8B, + "Archfiend": 0xBD, + "Armor Bee": 0x98, + "Armor goblin": 0x9D, + "Armour Dait": 0xEF, + "Armour Nail": 0xEB, + "Asashin": 0x82, + "Baby Frog": 0xBE, + "Basilisk": 0xB6, + "Bat": 0x8F, + "Beetle": 0x86, + "Behemoth": 0xB6, + "Big Bat": 0x8F, + "Big Mushr'm": 0xDB, + "Bili Jelly": 0xDE, + "Black Dragon": 0xC0, + "Blue Core": 0x95, + "Blue Dragon": 0xC0, + "Blue Jelly": 0xDD, + "Blue Mimic": 0xF0, + "Bone Gorem": 0xA0, + "Brinz Lizard": 0xEE, + "Brokion": 0xD3, + "Buffalo": 0x84, + "Cobalt": 0xA6, + "Cokatoris": 0xD2, + "Copper Dragon": 0xC0, + "Coridras": 0xEA, + "Crow": 0xB4, + "Crow Kelp": 0xBC, + "Cyclops": 0xB9, + "Dark Skull": 0xB5, + "Dark Spirit": 0xE7, + "Dark Sum'ner": 0xAB, + "Dark Warrior": 0xB0, + "Deadly Armor": 0x99, + "Deadly Sword": 0x90, + "Demise": 0xAD, + "Desert Rose": 0x96, + "Dragonian": 0xEF, + "Drill Shell": 0x81, + "Eagle": 0xB4, + "Earth Genie": 0xB9, + "Earth Viper": 0xB3, + "Evil Fish": 0x80, + "Fiend": 0xBD, + "Fighter ork": 0xA5, + "Flame genie": 0xB9, + "Garbost": 0xD8, + "Ghost Ship": 0xD1, + "Ghoul": 0xE1, + "Gnome": 0xA5, + "Goblin": 0x9D, + "Gold Dragon": 0xC0, + "Gold Gorem": 0xE2, + "Gorgon": 0xAA, + "Great Coca": 0xD2, + "Green Core": 0x95, + "Green Dragon": 0xC0, + "Grianos": 0xB6, + "Hade Chariot": 0xBA, + "Hades": 0xBA, + "Hades Skull": 0xB5, + "Hidora": 0xBF, + "High Hidora": 0xBF, + "Hound": 0x8A, + "Ice Roge": 0xBD, + "Imp": 0xAC, + "Iron Gorem": 0xA1, + "Jurahan": 0xD5, + "Leech": 0xAD, + "Lion": 0xB7, + "Lizard": 0x83, + "Lizardman": 0x9E, + "Lunar bear": 0x9B, + "Mad Ent": 0x8E, + "Mad Gorem": 0xA3, + "Mad Head": 0xAF, + "Mad horse": 0x85, + "Magma Gorem": 0xE3, + "Medusa": 0x9C, + "Mega Moth": 0xDC, + "Mega Cyclops": 0xB9, + "Mimic": 0xA4, + "Minataurus": 0xAA, + "Moray Vine": 0x9A, + "Mosquito": 0x92, + "Moth": 0x93, + "Mummy": 0xA8, + "Mushroom": 0x8C, + "Necromancer": 0xAB, + "Needle Lizard": 0xD6, + "Newt": 0x83, + "Ninja": 0x82, + "No Core": 0x95, + "Nosferato": 0x9F, + "Nuborg": 0xE5, + "Ochi Warrior": 0xB0, + "Ork": 0xA5, + "Orky": 0xBF, + "Poison Beetle": 0xD7, + "Pug": 0x8D, + "Pumpkin Head": 0xAF, + "Ramia": 0xAE, + "Red Bat": 0x8F, + "Red Core": 0x95, + "Red Dragon": 0xC0, + "Red Jelly": 0x94, + "Red Plant": 0xEC, + "Regal Goblin": 0x9D, + "Rogue Shape": 0xC4, + "Salamander": 0xC1, + "Samurai": 0xB0, + "Sand Gorem": 0xE4, + "Scorpion": 0x8B, + "Sea Hidora": 0xBF, + "Seirein": 0xAE, + "Sentopez": 0xDA, + "Serfaco": 0xE8, + "Shadow": 0xB2, + "Silver Dragon": 0xC0, + "Skeleton": 0xA0, + "Skull Lizard": 0x9E, + "Sly Fox": 0xED, + "Snow Gas": 0xD2, + "Specter": 0xE7, + "Sphinx": 0xB7, + "Spider": 0xD9, + "Spinner": 0xE9, + "Squid": 0x80, + "Stinger": 0x98, + "T Rex": 0xD3, + "Tartona": 0xB8, + "Tengu": 0xD4, + "Thunderbeast": 0x9B, + "Troll": 0xA9, + "Vampire": 0x9F, + "Vampire Rose": 0x96, + "Venus Fly": 0xE0, + "Waiban": 0xC3, + "Warm Eye": 0x88, + "Well Genie": 0xB9, + "Wheel Eel": 0x97, + "White Dragon": 0xC3, + "Wind Genie": 0xB9, + "Winger": 0xB1, + "Wispy": 0x91, + "Wizard": 0xAB, + "Wood Gorem": 0xA2, + "Zombie": 0xA7, +} diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py index 47b183c8..df71ef44 100644 --- a/worlds/lufia2ac/Options.py +++ b/worlds/lufia2ac/Options.py @@ -1,10 +1,16 @@ from __future__ import annotations import random -from itertools import chain, combinations -from typing import Any, cast, Dict, List, Optional, Set, Tuple +from dataclasses import dataclass +from itertools import accumulate, chain, combinations +from typing import Any, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Type, TYPE_CHECKING, Union -from Options import AssembleOptions, Choice, DeathLink, Option, Range, SpecialRange, TextChoice, Toggle +from Options import AssembleOptions, Choice, DeathLink, ItemDict, Range, SpecialRange, TextChoice, Toggle +from .Enemies import enemy_name_to_sprite + +if TYPE_CHECKING: + from BaseClasses import PlandoOptions + from worlds.AutoWorld import World class AssembleCustomizableChoices(AssembleOptions): @@ -37,6 +43,22 @@ class RandomGroupsChoice(Choice, metaclass=AssembleCustomizableChoices): return super().from_text(text) +class EnemyChoice(TextChoice): + _valid_sprites: Dict[str, int] = {enemy_name.lower(): sprite for enemy_name, sprite in enemy_name_to_sprite.items()} + + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: + if isinstance(self.value, int): + return + if str(self.value).lower() in self._valid_sprites: + return + raise ValueError(f"Could not find option '{self.value}' for '{self.__class__.__name__}', known options are:\n" + f"{', '.join(self.options)}, {', '.join(enemy_name_to_sprite)}.") + + @property + def sprite(self) -> Optional[int]: + return self._valid_sprites.get(str(self.value).lower()) + + class LevelMixin: xp_coefficients: List[int] = sorted([191, 65, 50, 32, 18, 14, 6, 3, 3, 2, 2, 2, 2] * 8, reverse=True) @@ -61,8 +83,7 @@ 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. + If you increase the blue chest chance, then the red chest chance is decreased in return. Supported values: 5 – 75 Default value: 25 (five times as much as in an unmodified game) """ @@ -72,6 +93,14 @@ class BlueChestChance(Range): range_end = 75 default = 25 + @property + def chest_type_thresholds(self) -> bytes: + ratio: float = (256 - self.value) / (256 - 5) + # unmodified chances are: consumable (mostly non-restorative) = 36/256, consumable (restorative) = 58/256, + # blue chest = 5/256, spell = 30/256, gear = 45/256 (and the remaining part, weapon = 82/256) + chest_type_chances: List[float] = [36 * ratio, 58 * ratio, float(self.value), 30 * ratio, 45 * ratio] + return bytes(round(threshold) for threshold in reversed(tuple(accumulate(chest_type_chances)))) + class BlueChestCount(Range): """The number of blue chest items that will be in your item pool. @@ -152,7 +181,7 @@ class Boss(RandomGroupsChoice): "random-high": ["venge_ghost", "white_dragon_x3", "fire_dragon", "ghost_ship", "tank"], "random-sinistral": ["gades_c", "amon", "erim", "daos"], } - extra_options = frozenset(random_groups) + extra_options = set(random_groups) @property def flag(self) -> int: @@ -242,6 +271,34 @@ class CrowdedFloorChance(Range): default = 16 +class CustomItemPool(ItemDict, Mapping[str, int]): + """Customize your multiworld item pool. + + Using this option you can place any cave item in your multiworld item pool. (By default, the pool is filled with + blue chest items.) Here you can add any valid item from the Lufia II Ancient Cave section of the datapackage + (see https://archipelago.gg/datapackage). The value of this option has to be a mapping of item name to count, + e.g., to add two Deadly rods and one Dekar Blade: {Deadly rod: 2, Dekar blade: 1} + The maximum total amount of custom items you can place is limited by the chosen blue_chest_count; any remaining, + non-customized space in the pool will be occupied by random blue chest items. + """ + + display_name = "Custom item pool" + value: Dict[str, int] + + @property + def count(self) -> int: + return sum(self.values()) + + def __getitem__(self, key: str) -> int: + return self.value.__getitem__(key) + + def __iter__(self) -> Iterator[str]: + return self.value.__iter__() + + def __len__(self) -> int: + return self.value.__len__() + + class DefaultCapsule(Choice): """Preselect the active capsule monster. @@ -277,7 +334,8 @@ class DefaultParty(RandomGroupsChoice, TextChoice): """ display_name = "Default party lineup" - default = "M" + default: Union[str, int] = "M" + value: Union[str, int] random_groups = { "random-2p": ["M" + "".join(p) for p in combinations("ADGLST", 1)], @@ -288,7 +346,7 @@ class DefaultParty(RandomGroupsChoice, TextChoice): _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: + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: if str(self.value).lower() in self.random_groups: return if sorted(str(self.value).upper()) in self._valid_sorted_parties: @@ -317,6 +375,97 @@ class DefaultParty(RandomGroupsChoice, TextChoice): return len(str(self.value)) +class EnemyFloorNumbers(Choice): + """Change which enemy types are encountered at which floor numbers. + + Supported values: + vanilla + Ninja, e.g., is allowed to appear on the 3 floors B44-B46 + shuffle — The existing enemy types are redistributed among nearby floors. Shifts by up to 6 floors are possible. + Ninja, e.g., will be allowed to appear on exactly 3 consecutive floors somewhere from B38-B40 to B50-B52 + randomize — For each floor, new enemy types are chosen randomly from the set usually possible on floors [-6, +6]. + Ninja, e.g., is among the various possible selections for any enemy slot affecting the floors from B38 to B52 + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy floor numbers" + option_vanilla = 0 + option_shuffle = 1 + option_randomize = 2 + default = option_vanilla + + +class EnemyMovementPatterns(EnemyChoice): + """Change the movement patterns of enemies. + + Supported values: + vanilla + shuffle_by_pattern — The existing movement patterns are redistributed among each other. + Sprites that usually share a movement pattern will still share movement patterns after shuffling + randomize_by_pattern — For each movement pattern, a new one is chosen randomly from the set of existing patterns. + Sprites that usually share a movement pattern will still share movement patterns after randomizing + shuffle_by_sprite — The existing movement patterns of sprites are redistributed among the enemy sprites. + Sprites that usually share a movement pattern can end up with different movement patterns after shuffling + randomize_by_sprite — For each sprite, a new movement is chosen randomly from the set of existing patterns. + Sprites that usually share a movement pattern can end up with different movement patterns after randomizing + singularity — All enemy sprites use the same, randomly selected movement pattern + Alternatively, you can directly specify an enemy name such as "Red Jelly" as the value of this option. + In that case, the movement pattern usually associated with this sprite will be used by all enemy sprites + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy movement patterns" + option_vanilla = 0 + option_shuffle_by_pattern = 1 + option_randomize_by_pattern = 2 + option_shuffle_by_sprite = 3 + option_randomize_by_sprite = 4 + option_singularity = 5 + default = option_vanilla + + +class EnemySprites(EnemyChoice): + """Change the appearance of enemies. + + Supported values: + vanilla + shuffle — The existing sprites are redistributed among the enemy types. + This means that, after shuffling, exactly 1 enemy type will be dressing up as the "Red Jelly" sprite + randomize — For each enemy type, a new sprite is chosen randomly from the set of existing sprites. + This means that, after randomizing, any number of enemy types could end up using the "Red Jelly" sprite + singularity — All enemies use the same, randomly selected sprite + Alternatively, you can directly specify an enemy name such as "Red Jelly" as the value of this option. + In this case, the sprite usually associated with that enemy will be used by all enemies + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy sprites" + option_vanilla = 0 + option_shuffle = 1 + option_randomize = 2 + option_singularity = 3 + default = option_vanilla + + +class ExpModifier(Range): + """Percentage modifier for EXP gained from enemies. + + Supported values: 100 – 500 + Default value: 100 (same as in an unmodified game) + """ + + display_name = "EXP modifier" + range_start = 100 + range_end = 500 + default = 100 + + def __call__(self, exp: bytes) -> bytes: + try: + return (int.from_bytes(exp, "little") * self.value // 100).to_bytes(2, "little") + except OverflowError: + return b"\xFF\xFF" + + class FinalFloor(Range): """The final floor, where the boss resides. @@ -424,28 +573,18 @@ class IrisTreasuresRequired(Range): default = 9 -class MasterHp(SpecialRange): +class MasterHp(Range): """The number of hit points of the Master - Supported values: - 1 – 9980, - scale — scales the HP depending on the value of final_floor + (Only has an effect if boss is set to master.) + Supported values: 1 – 9980 Default value: 9980 (same as in an unmodified game) """ display_name = "Master HP" - range_start = 0 + range_start = 1 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): @@ -503,7 +642,8 @@ class ShufflePartyMembers(Toggle): 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 + multiworld; when one of these items is found, the corresponding party member is unlocked for you to use. + While cave diving, you can add newly unlocked ones to your party by using the character items from the inventory Default value: false (same as in an unmodified game) """ @@ -514,27 +654,32 @@ class ShufflePartyMembers(Toggle): return 0b00000000 if self.value else 0b11111100 -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, - "default_capsule": DefaultCapsule, - "default_party": DefaultParty, - "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, -} +@dataclass +class L2ACOptions: + 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 + custom_item_pool: CustomItemPool + death_link: DeathLink + default_capsule: DefaultCapsule + default_party: DefaultParty + enemy_floor_numbers: EnemyFloorNumbers + enemy_movement_patterns: EnemyMovementPatterns + enemy_sprites: EnemySprites + exp_modifier: ExpModifier + 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 diff --git a/worlds/lufia2ac/Rom.py b/worlds/lufia2ac/Rom.py index ac168c88..1da8d235 100644 --- a/worlds/lufia2ac/Rom.py +++ b/worlds/lufia2ac/Rom.py @@ -22,15 +22,15 @@ class L2ACDeltaPatch(APDeltaPatch): 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"))) + file_path: str = get_base_rom_path(file_name) + base_rom_bytes = bytes(Utils.read_snes_rom(open(file_path, "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 + setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes) return base_rom_bytes diff --git a/worlds/lufia2ac/Utils.py b/worlds/lufia2ac/Utils.py new file mode 100644 index 00000000..6c2e28d1 --- /dev/null +++ b/worlds/lufia2ac/Utils.py @@ -0,0 +1,21 @@ +from random import Random +from typing import Dict, List, MutableSequence, Sequence, Set, Tuple + + +def constrained_choices(population: Sequence[int], d: int, *, k: int, random: Random) -> List[int]: + n: int = len(population) + constraints: Dict[int, Tuple[int, ...]] = { + i: tuple(dict.fromkeys(population[j] for j in range(max(0, i - d), min(i + d + 1, n)))) for i in range(n) + } + + return [random.choice(constraints[i]) for i in range(k)] + + +def constrained_shuffle(x: MutableSequence[int], d: int, random: Random) -> None: + n: int = len(x) + constraints: Dict[int, Set[int]] = {i: set(x[j] for j in range(max(0, i - d), min(i + d + 1, n))) for i in range(n)} + + for _ in range(d * n * n): + i, j = random.randrange(n), random.randrange(n) + if x[i] in constraints[j] and x[j] in constraints[i]: + x[i], x[j] = x[j], x[i] diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index e54f8428..587792a5 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -2,19 +2,21 @@ import base64 import itertools import os from enum import IntFlag -from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple +from random import Random +from typing import Any, ClassVar, Dict, get_type_hints, Iterator, List, Set, Tuple from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial -from Main import __version__ from Options import AssembleOptions +from Utils import __version__ 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, DefaultParty, Goal, l2ac_option_definitions, \ - MasterHp, PartyStartingLevel, ShuffleCapsuleMonsters, ShufflePartyMembers +from .Options import CapsuleStartingLevel, DefaultParty, EnemyFloorNumbers, EnemyMovementPatterns, EnemySprites, \ + ExpModifier, Goal, L2ACOptions from .Rom import get_base_rom_bytes, get_base_rom_path, L2ACDeltaPatch +from .Utils import constrained_choices, constrained_shuffle from .basepatch import apply_basepatch CHESTS_PER_SPHERE: int = 5 @@ -42,7 +44,7 @@ class L2ACWorld(World): game: ClassVar[str] = "Lufia II Ancient Cave" web: ClassVar[WebWorld] = L2ACWeb() - option_definitions: ClassVar[Dict[str, AssembleOptions]] = l2ac_option_definitions + option_definitions: ClassVar[Dict[str, AssembleOptions]] = get_type_hints(L2ACOptions) 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]]] = { @@ -54,30 +56,8 @@ class L2ACWorld(World): 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] - default_capsule: Optional[int] - default_party: Optional[DefaultParty] - 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] + rom_name: bytearray + o: L2ACOptions @classmethod def stage_assert_generate(cls, multiworld: MultiWorld) -> None: @@ -95,37 +75,17 @@ class L2ACWorld(World): 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.default_capsule = self.multiworld.default_capsule[self.player].value - self.default_party = self.multiworld.default_party[self.player] - 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.o = L2ACOptions(**{opt: getattr(self.multiworld, opt)[self.player] for opt in self.option_definitions}) - 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.default_party.value = DefaultParty.default + if self.o.blue_chest_count < self.o.custom_item_pool.count: + raise ValueError(f"Number of items in custom_item_pool ({self.o.custom_item_pool.count}) is " + f"greater than blue_chest_count ({self.o.blue_chest_count}).") + if self.o.capsule_starting_level == CapsuleStartingLevel.special_range_names["party_starting_level"]: + self.o.capsule_starting_level.value = int(self.o.party_starting_level) + if self.o.initial_floor >= self.o.final_floor: + self.o.initial_floor.value = self.o.final_floor - 1 + if self.o.shuffle_party_members: + self.o.default_party.value = DefaultParty.default def create_regions(self) -> None: menu = Region("Menu", self.player, self.multiworld) @@ -134,10 +94,10 @@ class L2ACWorld(World): ancient_dungeon = Region("AncientDungeon", self.player, self.multiworld, "Ancient Dungeon") ancient_dungeon.exits.append(Entrance(self.player, "FinalFloorEntrance", ancient_dungeon)) - item_count: int = self.blue_chest_count - if self.shuffle_capsule_monsters: + item_count: int = int(self.o.blue_chest_count) + if self.o.shuffle_capsule_monsters: item_count += len(self.item_name_groups["Capsule monsters"]) - if self.shuffle_party_members: + if self.o.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)) @@ -167,21 +127,23 @@ class L2ACWorld(World): .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: List[str] = self.multiworld.random.choices(sorted(self.item_name_groups["Blue chest items"]), + k=self.o.blue_chest_count - self.o.custom_item_pool.count) + item_pool += [item_name for item_name, count in self.o.custom_item_pool.items() for _ in range(count)] + + if self.o.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: + self.o.blue_chest_count.value += len(self.item_name_groups["Capsule monsters"]) + if self.o.shuffle_party_members: item_pool += self.item_name_groups["Party members"] - self.blue_chest_count += len(self.item_name_groups["Party members"]) + self.o.blue_chest_count.value += 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): + for i in range(1, self.o.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)) @@ -192,27 +154,27 @@ class L2ACWorld(World): 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)) + lambda state: state.can_reach(f"Blue chest {self.o.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)) + lambda state: state.can_reach(f"Blue chest {self.o.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: + lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player)) + if self.o.shuffle_capsule_monsters: add_rule(self.multiworld.get_location("Boss", self.player), lambda state: state.has("DARBI", self.player)) - if self.shuffle_party_members: + if self.o.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: + if self.o.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: + elif self.o.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: + elif self.o.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: + elif self.o.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) @@ -223,39 +185,45 @@ class L2ACWorld(World): 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.default_party.roster + rom_bytearray[0x014308:0x014308 + 1] = self.o.capsule_starting_level.value.to_bytes(1, "little") + rom_bytearray[0x01432F:0x01432F + 1] = self.o.capsule_starting_form.unlock.to_bytes(1, "little") + rom_bytearray[0x01433C:0x01433C + 1] = self.o.capsule_starting_form.value.to_bytes(1, "little") + rom_bytearray[0x0190D5:0x0190D5 + 1] = self.o.iris_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x019147:0x019157 + 1:4] = self.o.blue_chest_chance.chest_type_thresholds + rom_bytearray[0x019176] = 0x38 if self.o.gear_variety_after_b9 else 0x18 + rom_bytearray[0x019477:0x019477 + 1] = self.o.healing_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x0194A2:0x0194A2 + 1] = self.o.crowded_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x019E82:0x019E82 + 1] = self.o.final_floor.value.to_bytes(1, "little") + rom_bytearray[0x01FC75:0x01FC75 + 1] = self.o.run_speed.value.to_bytes(1, "little") + rom_bytearray[0x01FC81:0x01FC81 + 1] = self.o.run_speed.value.to_bytes(1, "little") + rom_bytearray[0x02B2A1:0x02B2A1 + 5] = self.o.default_party.roster for offset in range(0x02B395, 0x02B452, 0x1B): - rom_bytearray[offset:offset + 1] = self.party_starting_level.value.to_bytes(1, "little") + rom_bytearray[offset:offset + 1] = self.o.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[offset:offset + 3] = self.o.party_starting_level.xp.to_bytes(3, "little") rom_bytearray[0x05699E:0x05699E + 147] = self.get_goal_text_bytes() - rom_bytearray[0x056AA3:0x056AA3 + 24] = self.default_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[0x056AA3:0x056AA3 + 24] = self.o.default_party.event_script + rom_bytearray[0x072742:0x072742 + 1] = self.o.boss.value.to_bytes(1, "little") + rom_bytearray[0x072748:0x072748 + 1] = self.o.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.default_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[0x0B05C0:0x0B05C0 + 18843] = self.get_enemy_stats() + rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.o.master_hp.value.to_bytes(2, "little") + rom_bytearray[0x280010:0x280010 + 2] = self.o.blue_chest_count.value.to_bytes(2, "little") + rom_bytearray[0x280012:0x280012 + 3] = self.o.capsule_starting_level.xp.to_bytes(3, "little") + rom_bytearray[0x280015:0x280015 + 1] = self.o.initial_floor.value.to_bytes(1, "little") + rom_bytearray[0x280016:0x280016 + 1] = self.o.default_capsule.value.to_bytes(1, "little") + rom_bytearray[0x280017:0x280017 + 1] = self.o.iris_treasures_required.value.to_bytes(1, "little") + rom_bytearray[0x280018:0x280018 + 1] = self.o.shuffle_party_members.unlock.to_bytes(1, "little") + rom_bytearray[0x280019:0x280019 + 1] = self.o.shuffle_capsule_monsters.unlock.to_bytes(1, "little") + rom_bytearray[0x280030:0x280030 + 1] = self.o.goal.value.to_bytes(1, "little") + rom_bytearray[0x28003D:0x28003D + 1] = self.o.death_link.value.to_bytes(1, "little") rom_bytearray[0x281200:0x281200 + 470] = self.get_capsule_cravings_table() + (rom_bytearray[0x08A1D4:0x08A1D4 + 128], + rom_bytearray[0x0A595C:0x0A595C + 200], + rom_bytearray[0x0A5DF6:0x0A5DF6 + 192], + rom_bytearray[0x27F6B5:0x27F6B5 + 113]) = self.get_enemy_floors_sprites_and_movement_patterns() + with open(rom_path, "wb") as f: f.write(rom_bytearray) except Exception as e: @@ -276,13 +244,19 @@ class L2ACWorld(World): # end of ordered Main.py calls def create_item(self, name: str) -> Item: - item_data: ItemData = l2ac_item_table.get(name) + item_data: ItemData = l2ac_item_table[name] return L2ACItem(name, item_data.classification, items_start_id + item_data.code, self.player) + def get_filler_item_name(self) -> str: + return ["Potion", "Hi-Magic", "Miracle", "Hi-Potion", "Potion", "Ex-Potion", "Regain", "Ex-Magic", "Hi-Magic"][ + (self.multiworld.random.randrange(9) + self.multiworld.random.randrange(9)) // 2] + + # end of overridden AutoWorld.py methods + def get_capsule_cravings_table(self) -> bytes: rom: bytes = get_base_rom_bytes() - if self.capsule_cravings_jp_style: + if self.o.capsule_cravings_jp_style: number_of_items: int = 467 items_offset: int = 0x0B4F69 value_thresholds: List[int] = \ @@ -307,17 +281,92 @@ class L2ACWorld(World): else: return rom[0x0AFF16:0x0AFF16 + 470] + def get_enemy_floors_sprites_and_movement_patterns(self) -> Tuple[bytes, bytes, bytes, bytes]: + rom: bytes = get_base_rom_bytes() + + if self.o.enemy_floor_numbers == EnemyFloorNumbers.default \ + and self.o.enemy_sprites == EnemySprites.default \ + and self.o.enemy_movement_patterns == EnemyMovementPatterns.default: + return rom[0x08A1D4:0x08A1D4 + 128], rom[0x0A595C:0x0A595C + 200], \ + rom[0x0A5DF6:0x0A5DF6 + 192], rom[0x27F6B5:0x27F6B5 + 113] + + formations: bytes = rom[0x0A595C:0x0A595C + 200] + sprites: bytes = rom[0x0A5DF6:0x0A5DF6 + 192] + indices: bytes = rom[0x27F6B5:0x27F6B5 + 113] + pointers: List[bytes] = [rom[0x08A1D4 + 2 * index:0x08A1D4 + 2 * index + 2] for index in range(64)] + + used_formations: List[int] = list(formations) + formation_set: Set[int] = set(used_formations) + used_sprites: List[int] = [sprite for formation, sprite in enumerate(sprites) if formation in formation_set] + sprite_set: Set[int] = set(used_sprites) + used_indices: List[int] = [index for sprite, index in enumerate(indices, 128) if sprite in sprite_set] + index_set: Set[int] = set(used_indices) + used_pointers: List[bytes] = [pointer for index, pointer in enumerate(pointers) if index in index_set] + + slot_random: Random = self.multiworld.per_slot_randoms[self.player] + + d: int = 2 * 6 + if self.o.enemy_floor_numbers == EnemyFloorNumbers.option_shuffle: + constrained_shuffle(used_formations, d, random=slot_random) + elif self.o.enemy_floor_numbers == EnemyFloorNumbers.option_randomize: + used_formations = constrained_choices(used_formations, d, k=len(used_formations), random=slot_random) + + if self.o.enemy_sprites == EnemySprites.option_shuffle: + slot_random.shuffle(used_sprites) + elif self.o.enemy_sprites == EnemySprites.option_randomize: + used_sprites = slot_random.choices(tuple(dict.fromkeys(used_sprites)), k=len(used_sprites)) + elif self.o.enemy_sprites == EnemySprites.option_singularity: + used_sprites = [slot_random.choice(tuple(dict.fromkeys(used_sprites)))] * len(used_sprites) + elif self.o.enemy_sprites.sprite: + used_sprites = [self.o.enemy_sprites.sprite] * len(used_sprites) + + if self.o.enemy_movement_patterns == EnemyMovementPatterns.option_shuffle_by_pattern: + slot_random.shuffle(used_pointers) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_randomize_by_pattern: + used_pointers = slot_random.choices(tuple(dict.fromkeys(used_pointers)), k=len(used_pointers)) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_shuffle_by_sprite: + slot_random.shuffle(used_indices) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_randomize_by_sprite: + used_indices = slot_random.choices(tuple(dict.fromkeys(used_indices)), k=len(used_indices)) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_singularity: + used_indices = [slot_random.choice(tuple(dict.fromkeys(used_indices)))] * len(used_indices) + elif self.o.enemy_movement_patterns.sprite: + used_indices = [indices[self.o.enemy_movement_patterns.sprite - 128]] * len(used_indices) + + sprite_iter: Iterator[int] = iter(used_sprites) + index_iter: Iterator[int] = iter(used_indices) + pointer_iter: Iterator[bytes] = iter(used_pointers) + formations = bytes(used_formations) + sprites = bytes(next(sprite_iter) if form in formation_set else sprite for form, sprite in enumerate(sprites)) + indices = bytes(next(index_iter) if sprite in sprite_set else idx for sprite, idx in enumerate(indices, 128)) + pointers = [next(pointer_iter) if idx in index_set else pointer for idx, pointer in enumerate(pointers)] + return b"".join(pointers), formations, sprites, indices + + def get_enemy_stats(self) -> bytes: + rom: bytes = get_base_rom_bytes() + + if self.o.exp_modifier == ExpModifier.default: + return rom[0x0B05C0:0x0B05C0 + 18843] + + number_of_enemies: int = 224 + enemy_stats = bytearray(rom[0x0B05C0:0x0B05C0 + 18843]) + + for enemy_id in range(number_of_enemies): + pointer: int = int.from_bytes(enemy_stats[2 * enemy_id:2 * enemy_id + 2], "little") + enemy_stats[pointer + 29:pointer + 31] = self.o.exp_modifier(enemy_stats[pointer + 29:pointer + 31]) + return enemy_stats + 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: + iris: str = f"{self.o.iris_treasures_required} Iris treasure{'s' if self.o.iris_treasures_required > 1 else ''}" + if self.o.goal == Goal.option_boss: + goal_text = ["You have to defeat", f"the boss on B{self.o.final_floor}."] + elif self.o.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}."] + elif self.o.goal == Goal.option_boss_iris_treasure_hunt: + goal_text = ["You have to retrieve", f"{iris} and", f"defeat the boss on B{self.o.final_floor}."] + elif self.o.goal == Goal.option_final_floor: + goal_text = [f"You need to get to B{self.o.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)) diff --git a/worlds/lufia2ac/basepatch/basepatch.asm b/worlds/lufia2ac/basepatch/basepatch.asm index d263b3d4..a2ea539f 100644 --- a/worlds/lufia2ac/basepatch/basepatch.asm +++ b/worlds/lufia2ac/basepatch/basepatch.asm @@ -3,13 +3,13 @@ lorom org $DFFFFD ; expand ROM to 3MB DB "EOF" -org $80FFD8 ; expand SRAM to 16KB - DB $04 ; overwrites DB $03 +org $80FFD8 ; expand SRAM to 32KB + DB $05 ; overwrites DB $03 org $80809A ; patch copy protection - CMP $704000 ; overwrites CMP $702000 + CMP $710000 ; overwrites CMP $702000 org $8080A6 ; patch copy protection - CMP $704000 ; overwrites CMP $702000 + CMP $710000 ; overwrites CMP $702000 @@ -34,8 +34,8 @@ org $8AF681 ; skip gruberik lexis dialogue org $8EA349 ; skip ancient cave entrance dialogue DB $1C,$B0,$01 ; L2SASM JMP $8EA1AD+$01B0 -org $8EA384 ; skip ancient cave exit dialogue - DB $1C,$2B,$02 ; L2SASM JMP $8EA1AD+$022B +org $8EA384 ; reset architect mode, skip ancient cave exit dialogue + DB $1B,$E1,$1C,$2B,$02 ; clear flag $E1, L2SASM JMP $8EA1AD+$022B org $8EA565 ; skip ancient cave leaving dialogue DB $1C,$E9,$03 ; L2SASM JMP $8EA1AD+$03E9 @@ -108,11 +108,13 @@ Init: STX $4302 ; A-bus destination address $F02000 (SRAM) LDA.b #$F0 STA $4304 - STX $4305 ; transfer 8kB + LDX.w #$6000 + STX $4305 ; transfer 24kB LDA.b #$01 STA $420B ; start DMA channel 1 ; sign expanded SRAM PHB + TDC LDA.b #$3F LDX.w #$8000 LDY.w #$2000 @@ -213,6 +215,8 @@ RX: JSR SpecialItemGet SEP #$20 JSL $8EC1EF ; call chest opening routine (but without chest opening animation) + STZ $A7 ; cleanup + JSL $83AB4F ; cleanup +: SEP #$20 RTS @@ -268,11 +272,15 @@ SpecialItemUse: SBC.w #$01B1 ; party member items range from $01B2 to $01B7 BMI + ASL + TAX ASL ASL ADC.w #$FD2E STA $09B7 ; set pointer to L2SASM join script SEP #$20 + LDA $8ED8C7,X ; load predefined bitmask with a single bit set + BIT $077E ; check against EV flags $02 to $07 (party member flags) + BNE + ; abort if character already present LDA $07A9 ; load EV register $11 (party counter) CMP.b #$03 BPL + ; abort if party full @@ -593,18 +601,16 @@ FinalFloor: pushpc org $8488BB ; DB=$84, x=0, m=0 - SEC ; {carry clear = disable this feature, carry set = enable this feature} JSL Providence ; overwrites LDX.w #$1402 : STX $0A8D - NOP ; + NOP #2 pullpc Providence: LDX.w #$1402 ; (overwritten instruction) STX $0A8D ; (overwritten instruction) add Potion x10 - BCC + - LDX.w #$022D ; + LDX.w #$022D STX $0A8F ; add Providence -+: RTL + RTL @@ -646,6 +652,142 @@ StartInventory: +; architect mode +pushpc +org $8EA1E7 +base = $8EA1AD ; ancient cave entrance script base + DB $15,$E1 : DW .locked-base ; L2SASM JMP .locked if flag $E1 set + DB $08,"Did you like the layout",$03, \ + "of the last cave? I can",$03, \ + "lock it down and prevent",$03, \ + "the cave from changing.",$01 + DB $08,"Do you want to lock",$03, \ + "the cave layout?",$01 + DB $10,$02 : DW .cancel-base,.lock-base ; setup 2 choices: .cancel and .lock + DB $08,"Cancel",$0F,"LOCK IT DOWN!",$0B +.cancel: + DB $4C,$54,$00 ; play sound $54, END +.lock: + DB $5A,$05,$03,$7F,$37,$28,$56,$4C,$6B,$1A,$E1 ; shake, delay $28 f, stop shake, play sound $6B, set flag $E1 +.locked: + DB $08,"It's locked down.",$00 + warnpc $8EA344 +org $839018 + ; DB=$83, x=0, m=1 + JSL ArchitectMode ; overwrites LDA.b #$7E : PHA : PLB +pullpc + +ArchitectMode: +; check current mode + LDA $079A + BIT.b #$02 + BEQ + ; go to write mode if flag $E1 (i.e., bit $02 in $079A) not set +; read mode (replaying the locked down layout) + JSR ArchitectBlockAddress + LDA $F00000,X ; check if current block is marked as filled + BEQ + ; go to write mode if block unused + TDC + LDA.b #$36 + LDY.w #$0521 + INX + MVN $7E,$F0 ; restore 55 RNG values from $F00000,X to $7E0521 + INX + LDA $F00000,X + STA $0559 ; restore current RNG index from $F00000,X to $7E0559 + BRA ++ +; write mode (recording the layout) ++: JSR ArchitectClearBlocks + JSR ArchitectBlockAddress + LDA $7FE696 + STA $F00000,X ; mark block as used + TDC + LDA.b #$36 + LDX.w #$0521 + INY + MVN $F0,$7E ; backup 55 RNG values from $7E0521 to $F00000,Y + INY + LDA $7E0559 + STA $0000,Y ; backup current RNG index from $7E0559 to $F00000,Y + LDA.b #$7E ; (overwritten instruction) set DB=$7E + PHA ; (overwritten instruction) + PLB ; (overwritten instruction) +++: RTL + +ArchitectClearBlocks: + LDA $7FE696 ; read next floor number + CMP $D08015 ; compare initial floor number + BEQ + + BRL ++ ; skip if not initial floor ++: LDA.b #$F0 + PHA + PLB + !floor = 1 + while !floor < 99 ; mark all blocks as unused + STZ !floor*$40+$6000 + !floor #= !floor+1 + endwhile +++: RTS + +ArchitectBlockAddress: +; calculate target SRAM address + TDC + LDA $7FE696 ; read next floor number + REP #$20 + ASL #6 + ADC.w #$6000 ; target SRAM address = next_floor * $40 + $6000 + TAX + TAY + SEP #$20 + RTS + + + +; for architect mode: make red chest behavior for iris treasure replacements independent of current inventory +; by ensuring the same number of RNG calls, no matter if you have the iris item already or not +; (done by prefilling *all* chests first and potentially overwriting one of them with an iris item afterwards, +; instead of checking the iris item first and then potentially filling *one fewer* regular chest) +pushpc +org $8390C9 + ; DB=$96, x=0, m=1 + NOP ; overwrites LDY.w #$0000 + BRA + ; go to regular red chest generation +-: ; iris treasure handling happens below +org $839114 + ; DB=$7F, x=0, m=1 + NOP #36 ; overwrites all of providence handling + LDA.b #$83 ; (overwritten instruction from org $8391E9) set DB=$83 for floor layout generation + PHA ; (overwritten instruction from org $8391E9) + PLB ; (overwritten instruction from org $8391E9) + BRL ++ ; go to end ++: LDY.w #$0000 ; (overwritten instruction from org $8390C9) initialize chest index + ; red chests are filled below +org $8391E9 + ; DB=$7F, x=0, m=1 + NOP ; overwrites LDA.b #$83 : PHA : PLB + BRL - ; go to iris treasure handling +++: ; floor layout generation happens below +pullpc + + + +; for architect mode: make red chest behavior for spell replacements independent of currently learned spells +; by ensuring the same number of RNG calls, no matter if you have the spell already or not +pushpc +org $8391A6 + ; DB=$7F, x=0, m=1 + JSL SpellRNG ; overwrites LDA.b #$80 : STA $E747,Y + NOP +pullpc + +SpellRNG: + LDA.b #$80 ; (overwritten instruction) mark chest item as spell + STA $E747,Y ; (overwritten instruction) + JSL $8082C7 ; + JSL $8082C7 ; advance RNG twice + RTL + + + ; increase variety of red chest gear after B9 pushpc org $839176 @@ -891,3 +1033,4 @@ pullpc ; $F02800 2 received counter ; $F02802 2 processed counter ; $F02804 inf list of received items +; $F06000 inf architect mode RNG state backups diff --git a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 index e31ac74a..7d622537 100644 Binary files a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 and b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 differ diff --git a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md index 375c6732..d1247a9e 100644 --- a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md +++ b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md @@ -41,18 +41,26 @@ Your Party Leader will hold up the item they received when not in a fight or in - Choose a goal for your world. Possible goals are: 1) Reach the final floor; 2) Defeat the boss on the final floor; 3) Retrieve a (customizable) number of iris treasures from the cave; 4) Retrieve the iris treasures *and* defeat the boss - You can also randomize the goal; The blue-haired NPC in front of the cafe can tell you about the selected objective -- Customize (or randomize) the chances of encountering blue chests, healing tiles, iris treasures, etc. -- Customize (or randomize) the default party lineup and capsule monster -- Customize (or randomize) the party starting level as well as capsule monster level and form -- Customize (or randomize) the initial and final floor numbers -- Customize (or randomize) the boss that resides on the final floor +- Customize the chances of encountering blue chests, healing tiles, iris treasures, etc. +- Customize the default party lineup and capsule monster +- Customize the party starting level as well as capsule monster level and form +- Customize the initial and final floor numbers +- Customize the boss that resides on the final floor +- Customize the multiworld item pool. (By default, your pool is filled with random blue chest items, but you can place + any cave item you want instead) - Customize start inventory, i.e., begin every run with certain items or spells of your choice +- Adjust how much EXP is gained from enemies +- Randomize enemy movement patterns, enemy sprites, and which enemy types can appear at which floor numbers - Option to shuffle your party members and/or capsule monsters into the multiworld, meaning that someone will have to - find them in order to unlock them for you to use + find them in order to unlock them for you to use. While cave diving, you can add newly unlocked members to your party + by using the character items from your inventory ###### Quality of life: - Various streamlining tweaks (removed cutscenes, dialogue, transitions) +- You can elect to lock the cave layout for the next run, giving you exactly the same floors and red chest contents as + on your previous attempt. This functionality is accessed via the bald NPC behind the counter at the Ancient Cave + Entrance - Always start with Providence already in your inventory. (It is no longer obtained from red chests) - (optional) Run button that allows you to move at faster than normal speed diff --git a/worlds/lufia2ac/test/TestCustomItemPool.py b/worlds/lufia2ac/test/TestCustomItemPool.py new file mode 100644 index 00000000..97d4cab2 --- /dev/null +++ b/worlds/lufia2ac/test/TestCustomItemPool.py @@ -0,0 +1,57 @@ +from argparse import Namespace + +from BaseClasses import PlandoOptions +from Generate import handle_option +from . import L2ACTestBase +from ..Options import CustomItemPool + + +class TestEmpty(L2ACTestBase): + options = { + "custom_item_pool": {}, + } + + def test_empty(self) -> None: + self.assertEqual(0, len(self.get_items_by_name("Dekar blade"))) + + +class TestINeedDekarBlade(L2ACTestBase): + options = { + "custom_item_pool": { + "Dekar blade": 2, + }, + } + + def test_i_need_dekar_blade(self) -> None: + self.assertEqual(2, len(self.get_items_by_name("Dekar blade"))) + + +class TestVerifyCount(L2ACTestBase): + auto_construct = False + options = { + "custom_item_pool": { + "Dekar blade": 26, + }, + } + + def test_verify_count(self) -> None: + self.assertRaisesRegex(ValueError, + "Number of items in custom_item_pool \\(26\\) is greater than blue_chest_count \\(25\\)", + lambda: self.world_setup()) + + +class TestVerifyItemName(L2ACTestBase): + auto_construct = False + options = { + "custom_item_pool": { + "The car blade": 2, + }, + } + + def test_verify_item_name(self) -> None: + self.assertRaisesRegex(Exception, + "Item The car blade from option CustomItemPool\\(The car blade: 2\\) is not a " + "valid item name from Lufia II Ancient Cave\\. Did you mean 'Dekar blade'", + lambda: handle_option(Namespace(game="Lufia II Ancient Cave", name="Player"), + self.options, "custom_item_pool", CustomItemPool, + PlandoOptions(0))) diff --git a/worlds/lufia2ac/test/TestGoal.py b/worlds/lufia2ac/test/TestGoal.py index 06393ff1..6dc78e66 100644 --- a/worlds/lufia2ac/test/TestGoal.py +++ b/worlds/lufia2ac/test/TestGoal.py @@ -2,13 +2,12 @@ from . import L2ACTestBase class TestDefault(L2ACTestBase): - options = {} - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testNothing(self): + def test_nothing(self) -> None: self.assertBeatable(True) @@ -17,15 +16,15 @@ class TestShuffleCapsuleMonsters(L2ACTestBase): "shuffle_capsule_monsters": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name("DARBI") self.assertBeatable(True) - def testNoDarbi(self): + def test_no_darbi(self) -> None: self.collect_all_but(["Boss victory", "DARBI"]) self.assertBeatable(False) @@ -35,23 +34,23 @@ class TestShufflePartyMembers(L2ACTestBase): "shuffle_party_members": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name(["Dekar", "Guy", "Arty"]) self.assertBeatable(True) - def testNoDekar(self): + def test_no_dekar(self) -> None: self.collect_all_but(["Boss victory", "Dekar"]) self.assertBeatable(False) - def testNoGuy(self): + def test_no_guy(self) -> None: self.collect_all_but(["Boss victory", "Guy"]) self.assertBeatable(False) - def testNoArty(self): + def test_no_arty(self) -> None: self.collect_all_but(["Boss victory", "Arty"]) self.assertBeatable(False) @@ -62,26 +61,26 @@ class TestShuffleBoth(L2ACTestBase): "shuffle_party_members": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name(["Dekar", "Guy", "Arty", "DARBI"]) self.assertBeatable(True) - def testNoDekar(self): + def test_no_dekar(self) -> None: self.collect_all_but(["Boss victory", "Dekar"]) self.assertBeatable(False) - def testNoGuy(self): + def test_no_guy(self) -> None: self.collect_all_but(["Boss victory", "Guy"]) self.assertBeatable(False) - def testNoArty(self): + def test_no_arty(self) -> None: self.collect_all_but(["Boss victory", "Arty"]) self.assertBeatable(False) - def testNoDarbi(self): + def test_no_darbi(self) -> None: self.collect_all_but(["Boss victory", "DARBI"]) self.assertBeatable(False)