lufia2ac: new features, bug fixes, and more (#1549)

### New features

- ***Architect mode***
  Usually the cave is randomized by the game, meaning that each attempt will produce a different dungeon. However, with this new feature the player can, between runs, opt into keeping the same cave. If activated, they will then encounter the same floor layouts, same enemy spawns, and same red chest contents as on their previous attempt.   

- ***Custom item pool***
  Previously, the multiworld item pool consisted entirely of random blue chest items because, well, the permanent checks are blue chests and that's what one would normally get from these. While blue chest items often greatly increase your odds against regular enemies, being able to defeat the Master can be contingent on having an appropriate equipment setup of red chest items (such as Dekar blade) or even enemy drops (such as Hidora rock), most of which cannot normally be obtained from blue chests.
  With the custom item pool option, players now have the freedom to place any cave item into the multiworld itempool for their world.

- ***Enemy floor number, enemy sprite, and enemy movement pattern randomization***
  Experienced players can deduce a lot of information about the opposition they will be facing, for example: Given the current floor number, one can know in advance which of the enemy types will have a chance to spawn on that floor. And when seeing a particular enemy sprite, one can already know which enemy types one might have to face in battle if one were to come in contact with it, and also how that enemy group will move through the dungeon.
  Three new randomization options are added for players who want to spice up their game: one can shuffle which enemy types appear on which floor, one can shuffle which sprite is used by which enemy type, and one can shuffle which movement pattern is used by which sprite.

- ***EXP modifier***
  Just a simple multiplier option to allow people to level up faster. (For technical reasons, the maximum amount of EXP that can be awarded for a single enemy is limited to 65535, but even with the maximum allowed modifier of 500% there are only 6 enemy types in the cave that can reach this cap.)


### Balance change

- ***proportionally adjust chest type distribution to accommodate increased blue chest chance***
  One of the main problems that became apparent in the current version has to do with the distribution of chest contents. The game considers 6 categories, namely: consumable (mostly non-restorative), consumable (restorative), blue chest item, spell, gear, and weapon. Since only blue chests count as multiworld locations, we want to have a mechanism to customize the blue chest chance.
  Given how the chest types are detetermined in game, a naive implementation of an increased blue chest chance causes only the consumable chance to be decreased in return. In practice, this has resulted in some players of worlds with a high blue chest chance struggling (more than usual) to keep their party alive because they were always low on comsumables that restore HP and MP.
  The new algorithm tries to avoid this one-sided effect by having an increase in blue chest chance resulting in a decrease of all other types, calculated in such a way that the relative distribution of the other 5 categories stays (approximately) the same.


### Bug fixes

- ***prevent using party member items if character is already in party***
  This should have been changed at the same time that 6eb00621e39c930f5746f5f3c69a6bc19cd0e84a was made, but oh well... 

- ***fix glitched sprite when opening a chest immediately after receiving an item***
  When opening a chest right after receiving a multiworld item (such that there were two item get animations in the exact same iteration of the game main loop), the item from the chest would display an incorrect sprite in the wrong place. Fixed by cleaning up some relevant memory addresses after getting the multiworld item.

- ***fix death link***
  There was a condition in `deathlink_kill_player` that looked kinda smart (it checked the time against `last_death_link`), but actually wasn't smart at all because `deathlink_kill_player` is executed as an async task and the main thread will update `last_death_link` after creating the task, meaning that whether or not the incoming death link would actually be passed to the game seems to have been up to a race condition. Fixed by simply removing that check.


### Other

- ***add Lufia II Ancient Cave (and SMW) to the network diagram***
  These two games were missing from the SNES sector.

- ***implement get_filler_item_name***
  Place a restorative consumable instead of a completely random item. (Now the only known problem with item links in lufia2ac is... that noone has ever tested item links. But this should be an improvement at least. Anyway, now #1172 can come ;)
  And btw., if you think that the implementation of random selection in this method looks weird, that's because it is indeed weird. (It tries to recreate the algorithm that the game itself uses when it generates a replacement item for a chest that would contain a spell that the party already knows.)

- ***store all options in a dataclass***
  This is basically like using #993 (but without actual support from core). It makes the lufia2ac world code much nicer to maintain because one doesn't have to change 5 different places anymore when adding or renaming an option.

- ***remove master_hp.scale***
  I have to admit: `scale` was a mistake. Never have I seen a single option value cause so many user misconceptions. Some people assume it affects enemies other than the Master; some people assume it affects stats other than HP; and many people will just assume it is a magic option that will somehow counterbalance whatever settings combination they are currently trying to shoot themselves in the foot with.
  On top of that, the `scale` mechanism probably doesn't provide a good user experience even when used for its intended purpose (since having reached floor XY in general doesn't mean you will have the power to deplete XY% of the Masters usual HP; especially given that, due to the randomness of loot, you are never guaranteed to be able to defeat the vanilla Master even when you have cleared 100% of the floors).
  The intended target audience of the `master_hp` option are people who want to fight the Master (and know how to fight it), but also want to lessen (to a degree of their choosing) the harsh dependence on the specific equipment setups that are usually required to win this fight even when having done all 99 floors. They can achieve this by setting the `master_hp` option to a numeric value appropriate for the level of challenge they are seeking. Therefore, nothing of value should be lost by removing the special `scale` value from the `master_hp` option, while at the same time a major source of user confusion will be eliminated.

- ***typing***
  This (combined with the switch to the option dataclass) greatly reduces the typing problems in the lufia2ac world. The remaining typing errors mostly fall into 4 categories:
  1. Lambdas with defaults (which seem to be incorrectly reported as an error due to a mypy bug)
  1. Classmethods that return instances (which could probably be improved using PEP 673 "Self" types, but that would require Python 3.11 as the minimum supported version)
  1. Everything that inherits from TextChoice (which is a typing mess in core)
  1. Everything related to asar.py (which does not have proper typing and lies outside of this project)

## How was this tested?

https://discord.com/channels/731205301247803413/1080852357442707476 and others
This commit is contained in:
el-u 2023-03-20 17:04:57 +01:00 committed by GitHub
parent ff9f563d4a
commit 6d13dc4944
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1032 additions and 442 deletions

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 535 KiB

View File

@ -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"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -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")

382
worlds/lufia2ac/Enemies.py Normal file
View File

@ -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,
}

View File

@ -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

View File

@ -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

21
worlds/lufia2ac/Utils.py Normal file
View File

@ -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]

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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)))

View File

@ -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)