2023-03-21 01:26:03 +09:00
|
|
|
import binascii
|
2024-06-17 22:48:15 -04:00
|
|
|
import dataclasses
|
2023-03-21 01:26:03 +09:00
|
|
|
import os
|
2023-04-14 22:47:45 -07:00
|
|
|
import pkgutil
|
|
|
|
import tempfile
|
2023-11-25 15:07:02 -06:00
|
|
|
import typing
|
2025-03-07 19:24:58 -05:00
|
|
|
import logging
|
2024-12-13 16:49:30 -05:00
|
|
|
import re
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2023-11-25 15:07:02 -06:00
|
|
|
import bsdiff4
|
2023-07-05 22:39:35 +02:00
|
|
|
|
2023-11-25 15:07:02 -06:00
|
|
|
import settings
|
2025-02-01 22:03:49 +01:00
|
|
|
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld
|
2023-03-21 01:26:03 +09:00
|
|
|
from Fill import fill_restrictive
|
|
|
|
from worlds.AutoWorld import WebWorld, World
|
|
|
|
from .Common import *
|
2024-12-13 16:49:30 -05:00
|
|
|
from . import ItemIconGuessing
|
2023-11-25 15:07:02 -06:00
|
|
|
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
|
2024-12-05 06:06:52 -05:00
|
|
|
ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name,
|
|
|
|
links_awakening_item_name_groups)
|
2023-03-21 01:26:03 +09:00
|
|
|
from .LADXR import generator
|
|
|
|
from .LADXR.itempool import ItemPool as LADXRItemPool
|
2023-11-25 15:07:02 -06:00
|
|
|
from .LADXR.locations.constants import CHEST_ITEMS
|
|
|
|
from .LADXR.locations.instrument import Instrument
|
2024-06-17 22:48:15 -04:00
|
|
|
from .LADXR.logic import Logic as LADXRLogic
|
2023-03-21 01:26:03 +09:00
|
|
|
from .LADXR.main import get_parser
|
|
|
|
from .LADXR.settings import Settings as LADXRSettings
|
|
|
|
from .LADXR.worldSetup import WorldSetup as LADXRWorldSetup
|
|
|
|
from .Locations import (LinksAwakeningLocation, LinksAwakeningRegion,
|
2024-12-05 06:06:52 -05:00
|
|
|
create_regions_from_ladxr, get_locations_to_id,
|
|
|
|
links_awakening_location_name_groups)
|
2024-06-19 08:40:10 +02:00
|
|
|
from .Options import DungeonItemShuffle, ShuffleInstruments, LinksAwakeningOptions, ladx_option_groups
|
2024-06-16 04:31:32 +02:00
|
|
|
from .Rom import LADXDeltaPatch, get_base_rom_path
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
DEVELOPER_MODE = False
|
|
|
|
|
2023-05-29 01:17:30 +02:00
|
|
|
|
2023-07-05 22:39:35 +02:00
|
|
|
class LinksAwakeningSettings(settings.Group):
|
|
|
|
class RomFile(settings.UserFilePath):
|
|
|
|
"""File name of the Link's Awakening DX rom"""
|
|
|
|
copy_to = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
|
|
|
|
description = "LADX ROM File"
|
|
|
|
md5s = [LADXDeltaPatch.hash]
|
|
|
|
|
2023-07-09 06:17:24 -07:00
|
|
|
class RomStart(str):
|
|
|
|
"""
|
|
|
|
Set this to false to never autostart a rom (such as after patching)
|
|
|
|
true for operating system default program
|
|
|
|
Alternatively, a path to a program to open the .gbc file with
|
|
|
|
Examples:
|
|
|
|
Retroarch:
|
|
|
|
rom_start: "C:/RetroArch-Win64/retroarch.exe -L sameboy"
|
|
|
|
BizHawk:
|
|
|
|
rom_start: "C:/BizHawk-2.9-win-x64/EmuHawk.exe --lua=data/lua/connector_ladx_bizhawk.lua"
|
|
|
|
"""
|
|
|
|
|
|
|
|
class DisplayMsgs(settings.Bool):
|
|
|
|
"""Display message inside of Bizhawk"""
|
2023-07-05 22:39:35 +02:00
|
|
|
|
2023-07-09 06:17:24 -07:00
|
|
|
rom_file: RomFile = RomFile(RomFile.copy_to)
|
|
|
|
rom_start: typing.Union[RomStart, bool] = True
|
2023-07-05 22:39:35 +02:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
class LinksAwakeningWebWorld(WebWorld):
|
|
|
|
tutorials = [Tutorial(
|
|
|
|
"Multiworld Setup Guide",
|
|
|
|
"A guide to setting up Links Awakening DX for MultiWorld.",
|
|
|
|
"English",
|
|
|
|
"setup_en.md",
|
|
|
|
"setup/en",
|
|
|
|
["zig"]
|
|
|
|
)]
|
|
|
|
theme = "dirt"
|
2024-06-19 08:40:10 +02:00
|
|
|
option_groups = ladx_option_groups
|
2024-12-05 06:06:52 -05:00
|
|
|
options_presets: typing.Dict[str, typing.Dict[str, typing.Any]] = {
|
|
|
|
"Keysanity": {
|
|
|
|
"shuffle_nightmare_keys": "any_world",
|
|
|
|
"shuffle_small_keys": "any_world",
|
|
|
|
"shuffle_maps": "any_world",
|
|
|
|
"shuffle_compasses": "any_world",
|
|
|
|
"shuffle_stone_beaks": "any_world",
|
|
|
|
}
|
|
|
|
}
|
2023-07-05 22:39:35 +02:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
class LinksAwakeningWorld(World):
|
2023-03-23 13:22:42 -07:00
|
|
|
"""
|
|
|
|
After a previous adventure, Link is stranded on Koholint Island, full of mystery and familiar faces.
|
|
|
|
Gather the 8 Instruments of the Sirens to wake the Wind Fish, so that Link can go home!
|
|
|
|
"""
|
2023-05-29 01:17:30 +02:00
|
|
|
game = LINKS_AWAKENING # name of the game/world
|
2023-03-21 01:26:03 +09:00
|
|
|
web = LinksAwakeningWebWorld()
|
2024-06-17 22:48:15 -04:00
|
|
|
|
|
|
|
options_dataclass = LinksAwakeningOptions
|
|
|
|
options: LinksAwakeningOptions
|
2023-07-05 22:39:35 +02:00
|
|
|
settings: typing.ClassVar[LinksAwakeningSettings]
|
2023-03-21 01:26:03 +09:00
|
|
|
topology_present = True # show path to required location checks in spoiler
|
|
|
|
|
|
|
|
# ID of first item and location, could be hard-coded but code may be easier
|
|
|
|
# to read with this as a propery.
|
|
|
|
base_id = BASE_ID
|
|
|
|
# Instead of dynamic numbering, IDs could be part of data.
|
|
|
|
|
|
|
|
# The following two dicts are required for the generation to know which
|
|
|
|
# items exist. They could be generated from json or something else. They can
|
|
|
|
# include events, but don't have to since events will be placed manually.
|
|
|
|
item_name_to_id = {
|
|
|
|
item.item_name : BASE_ID + item.item_id for item in links_awakening_items
|
|
|
|
}
|
|
|
|
|
|
|
|
item_name_to_data = links_awakening_items_by_name
|
|
|
|
|
|
|
|
location_name_to_id = get_locations_to_id()
|
|
|
|
|
|
|
|
# Items can be grouped using their names to allow easy checking if any item
|
|
|
|
# from that group has been collected. Group names can also be used for !hint
|
2024-12-05 06:06:52 -05:00
|
|
|
item_name_groups = links_awakening_item_name_groups
|
|
|
|
|
|
|
|
location_name_groups = links_awakening_location_name_groups
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
prefill_dungeon_items = None
|
|
|
|
|
2024-06-17 22:48:15 -04:00
|
|
|
ladxr_settings: LADXRSettings
|
|
|
|
ladxr_logic: LADXRLogic
|
|
|
|
ladxr_itempool: LADXRItemPool
|
|
|
|
|
|
|
|
multi_key: bytearray
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2023-05-29 01:17:30 +02:00
|
|
|
rupees = {
|
2023-07-04 10:33:33 -07:00
|
|
|
ItemName.RUPEES_20: 20,
|
|
|
|
ItemName.RUPEES_50: 50,
|
2023-05-29 01:17:30 +02:00
|
|
|
ItemName.RUPEES_100: 100,
|
|
|
|
ItemName.RUPEES_200: 200,
|
|
|
|
ItemName.RUPEES_500: 500,
|
|
|
|
}
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
def convert_ap_options_to_ladxr_logic(self):
|
2024-06-17 22:48:15 -04:00
|
|
|
self.ladxr_settings = LADXRSettings(dataclasses.asdict(self.options))
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2024-06-17 22:48:15 -04:00
|
|
|
self.ladxr_settings.validate()
|
2023-03-21 01:26:03 +09:00
|
|
|
world_setup = LADXRWorldSetup()
|
2024-06-17 22:48:15 -04:00
|
|
|
world_setup.randomize(self.ladxr_settings, self.random)
|
|
|
|
self.ladxr_logic = LADXRLogic(configuration_options=self.ladxr_settings, world_setup=world_setup)
|
2025-01-15 21:42:19 -05:00
|
|
|
self.ladxr_itempool = LADXRItemPool(self.ladxr_logic, self.ladxr_settings, self.random, bool(self.options.stabilize_item_pool)).toDict()
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2025-01-14 13:52:58 -05:00
|
|
|
def generate_early(self) -> None:
|
|
|
|
self.dungeon_item_types = {
|
|
|
|
}
|
|
|
|
for dungeon_item_type in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks", "instruments"]:
|
|
|
|
option_name = "shuffle_" + dungeon_item_type
|
|
|
|
option: DungeonItemShuffle = getattr(self.options, option_name)
|
|
|
|
|
|
|
|
self.dungeon_item_types[option.ladxr_item] = option.value
|
|
|
|
|
|
|
|
# The color dungeon does not contain an instrument
|
|
|
|
num_items = 8 if dungeon_item_type == "instruments" else 9
|
|
|
|
|
|
|
|
# For any and different world, set item rule instead
|
|
|
|
if option.value == DungeonItemShuffle.option_own_world:
|
|
|
|
self.options.local_items.value |= {
|
|
|
|
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
|
|
|
|
}
|
|
|
|
elif option.value == DungeonItemShuffle.option_different_world:
|
|
|
|
self.options.non_local_items.value |= {
|
|
|
|
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, num_items + 1)
|
|
|
|
}
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
def create_regions(self) -> None:
|
|
|
|
# Initialize
|
|
|
|
self.convert_ap_options_to_ladxr_logic()
|
|
|
|
regions = create_regions_from_ladxr(self.player, self.multiworld, self.ladxr_logic)
|
|
|
|
self.multiworld.regions += regions
|
|
|
|
|
|
|
|
# Connect Menu -> Start
|
|
|
|
start = None
|
|
|
|
for region in regions:
|
|
|
|
if region.name == "Start House":
|
|
|
|
start = region
|
|
|
|
break
|
|
|
|
|
|
|
|
assert(start)
|
|
|
|
|
2025-03-07 19:24:58 -05:00
|
|
|
menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld)
|
2023-03-21 01:26:03 +09:00
|
|
|
menu_region.exits = [Entrance(self.player, "Start Game", menu_region)]
|
|
|
|
menu_region.exits[0].connect(start)
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
self.multiworld.regions.append(menu_region)
|
|
|
|
|
|
|
|
# Place RAFT, other access events
|
|
|
|
for region in regions:
|
|
|
|
for loc in region.locations:
|
2024-04-14 13:37:48 -05:00
|
|
|
if loc.address is None:
|
2023-03-21 01:26:03 +09:00
|
|
|
loc.place_locked_item(self.create_event(loc.ladxr_item.event))
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
# Connect Windfish -> Victory
|
|
|
|
windfish = self.multiworld.get_region("Windfish", self.player)
|
|
|
|
l = Location(self.player, "Windfish", parent=windfish)
|
|
|
|
windfish.locations = [l]
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
l.place_locked_item(self.create_event("An Alarm Clock"))
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
self.multiworld.completion_condition[self.player] = lambda state: state.has("An Alarm Clock", player=self.player)
|
|
|
|
|
|
|
|
def create_item(self, item_name: str):
|
|
|
|
return LinksAwakeningItem(self.item_name_to_data[item_name], self, self.player)
|
|
|
|
|
|
|
|
def create_event(self, event: str):
|
|
|
|
return Item(event, ItemClassification.progression, None, self.player)
|
|
|
|
|
2023-04-27 20:30:13 -07:00
|
|
|
def create_items(self) -> None:
|
2025-03-07 19:24:58 -05:00
|
|
|
itempool = []
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
|
|
|
|
|
|
|
|
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
|
|
|
|
self.prefill_own_dungeons = []
|
2023-04-27 20:30:13 -07:00
|
|
|
self.pre_fill_items = []
|
2023-03-21 01:26:03 +09:00
|
|
|
# option_original_dungeon = 0
|
|
|
|
# option_own_dungeons = 1
|
|
|
|
# option_own_world = 2
|
|
|
|
# option_any_world = 3
|
|
|
|
# option_different_world = 4
|
|
|
|
# option_delete = 5
|
|
|
|
|
|
|
|
for ladx_item_name, count in self.ladxr_itempool.items():
|
|
|
|
# event
|
|
|
|
if ladx_item_name not in ladxr_item_to_la_item_name:
|
|
|
|
continue
|
|
|
|
item_name = ladxr_item_to_la_item_name[ladx_item_name]
|
|
|
|
for _ in range(count):
|
|
|
|
if item_name in exclude:
|
|
|
|
exclude.remove(item_name) # this is destructive. create unique list above
|
2025-01-15 21:42:19 -05:00
|
|
|
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
|
2023-03-21 01:26:03 +09:00
|
|
|
else:
|
|
|
|
item = self.create_item(item_name)
|
|
|
|
|
2024-06-17 22:48:15 -04:00
|
|
|
if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
|
2023-04-20 00:12:53 -07:00
|
|
|
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
|
|
|
|
location.place_locked_item(item)
|
2023-07-13 18:14:04 -07:00
|
|
|
location.show_in_spoiler = False
|
2023-03-21 01:26:03 +09:00
|
|
|
continue
|
2023-04-27 20:30:13 -07:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
if isinstance(item.item_data, DungeonItemData):
|
2024-03-10 14:48:00 +01:00
|
|
|
item_type = item.item_data.ladxr_id[:-1]
|
2025-01-14 13:52:58 -05:00
|
|
|
shuffle_type = self.dungeon_item_types[item_type]
|
2024-03-10 14:48:00 +01:00
|
|
|
|
|
|
|
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
|
2023-03-21 01:26:03 +09:00
|
|
|
# Find instrument, lock
|
|
|
|
# TODO: we should be able to pinpoint the region we want, save a lookup table please
|
|
|
|
found = False
|
2023-10-29 19:47:37 +01:00
|
|
|
for r in self.multiworld.get_regions(self.player):
|
2023-03-21 01:26:03 +09:00
|
|
|
if r.dungeon_index != item.item_data.dungeon_index:
|
|
|
|
continue
|
|
|
|
for loc in r.locations:
|
|
|
|
if not isinstance(loc, LinksAwakeningLocation):
|
|
|
|
continue
|
|
|
|
if not isinstance(loc.ladxr_item, Instrument):
|
|
|
|
continue
|
|
|
|
loc.place_locked_item(item)
|
|
|
|
found = True
|
|
|
|
break
|
|
|
|
if found:
|
2024-03-10 14:48:00 +01:00
|
|
|
break
|
2023-03-21 01:26:03 +09:00
|
|
|
else:
|
|
|
|
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
|
|
|
|
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
|
2023-04-27 20:30:13 -07:00
|
|
|
self.pre_fill_items.append(item)
|
2023-03-21 01:26:03 +09:00
|
|
|
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
|
|
|
|
self.prefill_own_dungeons.append(item)
|
2023-04-27 20:30:13 -07:00
|
|
|
self.pre_fill_items.append(item)
|
2023-03-21 01:26:03 +09:00
|
|
|
else:
|
2025-03-07 19:24:58 -05:00
|
|
|
itempool.append(item)
|
2023-03-21 01:26:03 +09:00
|
|
|
else:
|
2025-03-07 19:24:58 -05:00
|
|
|
itempool.append(item)
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2023-03-23 13:23:58 -07:00
|
|
|
self.multi_key = self.generate_multi_key()
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
# Add special case for trendy shop access
|
|
|
|
trendy_region = self.multiworld.get_region("Trendy Shop", self.player)
|
|
|
|
event_location = Location(self.player, "Can Play Trendy Game", parent=trendy_region)
|
|
|
|
trendy_region.locations.insert(0, event_location)
|
|
|
|
event_location.place_locked_item(self.create_event("Can Play Trendy Game"))
|
2025-03-07 19:24:58 -05:00
|
|
|
|
|
|
|
self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
|
2023-10-29 19:47:37 +01:00
|
|
|
for r in self.multiworld.get_regions(self.player):
|
2023-03-21 01:26:03 +09:00
|
|
|
# Set aside dungeon locations
|
|
|
|
if r.dungeon_index:
|
2023-04-27 20:30:13 -07:00
|
|
|
self.dungeon_locations_by_dungeon[r.dungeon_index - 1] += r.locations
|
2023-03-21 01:26:03 +09:00
|
|
|
for location in r.locations:
|
2023-04-27 20:30:13 -07:00
|
|
|
# Don't place dungeon items on pit button chest, to reduce chance of the filler blowing up
|
|
|
|
# TODO: no need for this if small key shuffle
|
|
|
|
if location.name == "Pit Button Chest (Tail Cave)" or location.item:
|
|
|
|
self.dungeon_locations_by_dungeon[r.dungeon_index - 1].remove(location)
|
2023-03-21 01:26:03 +09:00
|
|
|
# Properly fill locations within dungeon
|
|
|
|
location.dungeon = r.dungeon_index
|
|
|
|
|
2025-03-07 19:24:58 -05:00
|
|
|
if self.options.tarins_gift != "any_item":
|
|
|
|
self.force_start_item(itempool)
|
|
|
|
|
|
|
|
|
|
|
|
self.multiworld.itempool += itempool
|
2024-03-02 21:28:26 -08:00
|
|
|
|
2025-03-07 19:24:58 -05:00
|
|
|
def force_start_item(self, itempool):
|
2023-07-04 10:33:33 -07:00
|
|
|
start_loc = self.multiworld.get_location("Tarin's Gift (Mabe Village)", self.player)
|
|
|
|
if not start_loc.item:
|
2025-03-07 19:24:58 -05:00
|
|
|
"""
|
|
|
|
Find an item that forces progression or a bush breaker for the player, depending on settings.
|
|
|
|
"""
|
|
|
|
def is_possible_start_item(item):
|
|
|
|
return item.advancement and item.name not in self.options.non_local_items
|
|
|
|
|
|
|
|
def opens_new_regions(item):
|
|
|
|
collection_state = base_collection_state.copy()
|
2025-03-25 17:30:25 -04:00
|
|
|
collection_state.collect(item, prevent_sweep=True)
|
|
|
|
collection_state.sweep_for_advancements(self.get_locations())
|
2025-03-07 19:24:58 -05:00
|
|
|
return len(collection_state.reachable_regions[self.player]) > reachable_count
|
|
|
|
|
|
|
|
start_items = [item for item in itempool if is_possible_start_item(item)]
|
|
|
|
self.random.shuffle(start_items)
|
|
|
|
|
|
|
|
if self.options.tarins_gift == "bush_breaker":
|
|
|
|
start_item = next((item for item in start_items if item.name in links_awakening_item_name_groups["Bush Breakers"]), None)
|
|
|
|
|
|
|
|
else: # local_progression
|
|
|
|
entrance_mapping = self.ladxr_logic.world_setup.entrance_mapping
|
|
|
|
# Tail key opens a region but not a location if d1 entrance is not mapped to d1 or d4
|
|
|
|
# exclude it in these cases to avoid fill errors
|
|
|
|
if entrance_mapping['d1'] not in ['d1', 'd4']:
|
|
|
|
start_items = [item for item in start_items if item.name != 'Tail Key']
|
|
|
|
# Exclude shovel unless starting in Mabe Village
|
|
|
|
if entrance_mapping['start_house'] not in ['start_house', 'shop']:
|
|
|
|
start_items = [item for item in start_items if item.name != 'Shovel']
|
|
|
|
base_collection_state = CollectionState(self.multiworld)
|
2025-03-25 17:30:25 -04:00
|
|
|
base_collection_state.sweep_for_advancements(self.get_locations())
|
2025-03-07 19:24:58 -05:00
|
|
|
reachable_count = len(base_collection_state.reachable_regions[self.player])
|
|
|
|
start_item = next((item for item in start_items if opens_new_regions(item)), None)
|
|
|
|
|
|
|
|
if start_item:
|
|
|
|
itempool.remove(start_item)
|
2023-07-04 10:33:33 -07:00
|
|
|
start_loc.place_locked_item(start_item)
|
2025-03-07 19:24:58 -05:00
|
|
|
else:
|
|
|
|
logging.getLogger("Link's Awakening Logger").warning(f"No {self.options.tarins_gift.current_option_name} available for Tarin's Gift.")
|
|
|
|
|
2023-07-04 10:33:33 -07:00
|
|
|
|
2023-04-27 20:30:13 -07:00
|
|
|
def get_pre_fill_items(self):
|
|
|
|
return self.pre_fill_items
|
|
|
|
|
|
|
|
def pre_fill(self) -> None:
|
|
|
|
allowed_locations_by_item = {}
|
|
|
|
|
|
|
|
|
|
|
|
# Set up filter rules
|
|
|
|
|
|
|
|
# set containing the list of all possible dungeon locations for the player
|
|
|
|
all_dungeon_locs = set()
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-04-27 20:30:13 -07:00
|
|
|
# Do dungeon specific things
|
2023-03-21 01:26:03 +09:00
|
|
|
for dungeon_index in range(0, 9):
|
2023-04-27 20:30:13 -07:00
|
|
|
# set up allow-list for dungeon specific items
|
2023-07-04 10:33:33 -07:00
|
|
|
locs = set(loc for loc in self.dungeon_locations_by_dungeon[dungeon_index] if not loc.item)
|
2023-04-27 20:30:13 -07:00
|
|
|
for item in self.prefill_original_dungeon[dungeon_index]:
|
|
|
|
allowed_locations_by_item[item] = locs
|
|
|
|
|
|
|
|
# ...and gather the list of all dungeon locations
|
|
|
|
all_dungeon_locs |= locs
|
|
|
|
# ...also set the rules for the dungeon
|
|
|
|
for location in locs:
|
|
|
|
orig_rule = location.item_rule
|
2025-03-07 19:24:58 -05:00
|
|
|
# If an item is about to be placed on a dungeon location, it can go there iff
|
2023-04-27 20:30:13 -07:00
|
|
|
# 1. it fits the general rules for that location (probably 'return True' for most places)
|
|
|
|
# 2. Either
|
|
|
|
# 2a. it's not a restricted dungeon item
|
|
|
|
# 2b. it's a restricted dungeon item and this location is specified as allowed
|
|
|
|
location.item_rule = lambda item, location=location, orig_rule=orig_rule: \
|
|
|
|
(item not in allowed_locations_by_item or location in allowed_locations_by_item[item]) and orig_rule(item)
|
|
|
|
|
|
|
|
# Now set up the allow-list for any-dungeon items
|
|
|
|
for item in self.prefill_own_dungeons:
|
|
|
|
# They of course get to go in any spot
|
|
|
|
allowed_locations_by_item[item] = all_dungeon_locs
|
|
|
|
|
|
|
|
# Get the list of locations and shuffle
|
2023-07-04 10:33:33 -07:00
|
|
|
all_dungeon_locs_to_fill = sorted(all_dungeon_locs)
|
|
|
|
|
2024-06-17 22:48:15 -04:00
|
|
|
self.random.shuffle(all_dungeon_locs_to_fill)
|
2023-04-27 20:30:13 -07:00
|
|
|
|
|
|
|
# Get the list of items and sort by priority
|
|
|
|
def priority(item):
|
|
|
|
# 0 - Nightmare dungeon-specific
|
|
|
|
# 1 - Key dungeon-specific
|
|
|
|
# 2 - Other dungeon-specific
|
|
|
|
# 3 - Nightmare any local dungeon
|
|
|
|
# 4 - Key any local dungeon
|
|
|
|
# 5 - Other any local dungeon
|
|
|
|
i = 2
|
|
|
|
if "Nightmare" in item.name:
|
|
|
|
i = 0
|
|
|
|
elif "Key" in item.name:
|
|
|
|
i = 1
|
|
|
|
if allowed_locations_by_item[item] is all_dungeon_locs:
|
|
|
|
i += 3
|
|
|
|
return i
|
2025-02-01 22:03:49 +01:00
|
|
|
all_dungeon_items_to_fill = self.get_pre_fill_items()
|
2023-04-27 20:30:13 -07:00
|
|
|
all_dungeon_items_to_fill.sort(key=priority)
|
|
|
|
|
|
|
|
# Set up state
|
2025-02-01 22:03:49 +01:00
|
|
|
partial_all_state = CollectionState(self.multiworld)
|
|
|
|
# Collect every item from the item pool and every pre-fill item like MultiWorld.get_all_state, except not our own pre-fill items.
|
|
|
|
for item in self.multiworld.itempool:
|
|
|
|
partial_all_state.collect(item, prevent_sweep=True)
|
|
|
|
for player in self.multiworld.player_ids:
|
|
|
|
if player == self.player:
|
|
|
|
# Don't collect the items we're about to place.
|
|
|
|
continue
|
|
|
|
subworld = self.multiworld.worlds[player]
|
|
|
|
for item in subworld.get_pre_fill_items():
|
|
|
|
partial_all_state.collect(item, prevent_sweep=True)
|
|
|
|
|
|
|
|
# Sweep to pick up already placed items that are reachable with everything but the dungeon items.
|
|
|
|
partial_all_state.sweep_for_advancements()
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2025-02-01 22:03:49 +01:00
|
|
|
fill_restrictive(self.multiworld, partial_all_state, all_dungeon_locs_to_fill, all_dungeon_items_to_fill, lock=True, single_player_placement=True, allow_partial=False)
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
name_cache = {}
|
|
|
|
# Tries to associate an icon from another game with an icon we have
|
2024-12-13 16:49:30 -05:00
|
|
|
def guess_icon_for_other_world(self, foreign_item):
|
2023-03-21 01:26:03 +09:00
|
|
|
if not self.name_cache:
|
|
|
|
for item in ladxr_item_to_la_item_name.keys():
|
|
|
|
self.name_cache[item] = item
|
|
|
|
splits = item.split("_")
|
|
|
|
for word in item.split("_"):
|
2024-12-13 16:49:30 -05:00
|
|
|
if word not in ItemIconGuessing.BLOCKED_ASSOCIATIONS and not word.isnumeric():
|
2023-03-21 01:26:03 +09:00
|
|
|
self.name_cache[word] = item
|
2024-12-13 16:49:30 -05:00
|
|
|
for name in ItemIconGuessing.SYNONYMS.values():
|
2023-03-21 01:26:03 +09:00
|
|
|
assert name in self.name_cache, name
|
|
|
|
assert name in CHEST_ITEMS, name
|
2024-12-13 16:49:30 -05:00
|
|
|
self.name_cache.update(ItemIconGuessing.SYNONYMS)
|
|
|
|
pluralizations = {k + "S": v for k, v in self.name_cache.items()}
|
|
|
|
self.name_cache = pluralizations | self.name_cache
|
|
|
|
|
|
|
|
uppered = foreign_item.name.upper()
|
|
|
|
foreign_game = self.multiworld.game[foreign_item.player]
|
|
|
|
phrases = ItemIconGuessing.PHRASES.copy()
|
|
|
|
if foreign_game in ItemIconGuessing.GAME_SPECIFIC_PHRASES:
|
|
|
|
phrases.update(ItemIconGuessing.GAME_SPECIFIC_PHRASES[foreign_game])
|
|
|
|
|
|
|
|
for phrase, icon in phrases.items():
|
LADX: Add more specific "item icon guessing" support for some games (#4706)
* DKC3, PKMN R/B/Em, M&L specific item matches
* MLSS Bean types are now discrete
* Add Doom 1/2 items
* Add Doom 1/2 items, actually
* Add Inscryption items
* Add more SA2B items, Minecraft
* Add VVVVVV
* Add misc items, comma fixes
* Hat in Time items
* Misc changes
* Expand TODO
* Add more OoT items, Pokemon consumables
* KH2
* KH1, adjust KH2 items
* Formatting fixes
* more item changes, fix kh1 name
* Fix KH1 name
* Add Full Heal to MEDICINE graphics
* Final comma fixes before PR
* Add Full Restore as Medicine
* Move some names to generic, drink fixes, double-quotes consistency fix
* moved ROCK SMASH match to PHRASES dict
* Removed some redundant name checks, remove Old Amber check from Emerald
* Added "PASS" generic check as "LETTER" sprite
* Removed TODO
* Corrected KH1 name for real this time
* Icon assignment now uppers freogin item string during comparison
* Doom skull keys are now NIGHTMARE_KEY, added QUILL as generic for FEATHER
* KH2 armor is Blunic, accessories are Ribbons
* KH1 accessories/armor are Blunic
* "ROCK SMASH" is now "BOMB"
* Removed extra space
2025-03-17 11:50:57 -04:00
|
|
|
if phrase.upper() in uppered:
|
2024-12-13 16:49:30 -05:00
|
|
|
return icon
|
|
|
|
# pattern for breaking down camelCase, also separates out digits
|
|
|
|
pattern = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-zA-Z])(?=\d)")
|
|
|
|
possibles = pattern.sub(' ', foreign_item.name).upper()
|
|
|
|
for ch in "[]()_":
|
|
|
|
possibles = possibles.replace(ch, " ")
|
|
|
|
possibles = possibles.split()
|
2023-03-21 01:26:03 +09:00
|
|
|
for name in possibles:
|
|
|
|
if name in self.name_cache:
|
|
|
|
return self.name_cache[name]
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
return "TRADING_ITEM_LETTER"
|
|
|
|
|
2024-06-16 04:31:32 +02:00
|
|
|
@classmethod
|
|
|
|
def stage_assert_generate(cls, multiworld: MultiWorld):
|
|
|
|
rom_file = get_base_rom_path()
|
|
|
|
if not os.path.exists(rom_file):
|
|
|
|
raise FileNotFoundError(rom_file)
|
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
def generate_output(self, output_directory: str):
|
|
|
|
# copy items back to locations
|
|
|
|
for r in self.multiworld.get_regions(self.player):
|
|
|
|
for loc in r.locations:
|
|
|
|
if isinstance(loc, LinksAwakeningLocation):
|
|
|
|
assert(loc.item)
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
# If we're a links awakening item, just use the item
|
|
|
|
if isinstance(loc.item, LinksAwakeningItem):
|
|
|
|
loc.ladxr_item.item = loc.item.item_data.ladxr_id
|
|
|
|
|
2023-07-04 10:33:33 -07:00
|
|
|
# If the item name contains "sword", use a sword icon, etc
|
2023-03-21 01:26:03 +09:00
|
|
|
# Otherwise, use a cute letter as the icon
|
2024-12-13 16:49:30 -05:00
|
|
|
elif self.options.foreign_item_icons == 'guess_by_name':
|
|
|
|
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item)
|
2025-01-16 21:59:19 -05:00
|
|
|
loc.ladxr_item.setCustomItemName(loc.item.name)
|
2024-12-13 16:49:30 -05:00
|
|
|
|
2023-03-21 01:26:03 +09:00
|
|
|
else:
|
2024-12-13 16:49:30 -05:00
|
|
|
if loc.item.advancement:
|
|
|
|
loc.ladxr_item.item = 'PIECE_OF_POWER'
|
|
|
|
else:
|
|
|
|
loc.ladxr_item.item = 'GUARDIAN_ACORN'
|
2023-03-21 01:26:03 +09:00
|
|
|
loc.ladxr_item.custom_item_name = loc.item.name
|
|
|
|
|
|
|
|
if loc.item:
|
|
|
|
loc.ladxr_item.item_owner = loc.item.player
|
|
|
|
else:
|
|
|
|
loc.ladxr_item.item_owner = self.player
|
|
|
|
|
|
|
|
# Kind of kludge, make it possible for the location to differentiate between local and remote items
|
|
|
|
loc.ladxr_item.location_owner = self.player
|
|
|
|
|
2023-07-09 06:17:24 -07:00
|
|
|
rom_name = Rom.get_base_rom_path()
|
2024-06-17 22:48:15 -04:00
|
|
|
out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.player_name}.gbc"
|
2023-04-11 00:18:33 -07:00
|
|
|
out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc")
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
parser = get_parser()
|
2023-04-11 00:18:33 -07:00
|
|
|
args = parser.parse_args([rom_name, "-o", out_name, "--dump"])
|
2023-03-21 01:26:03 +09:00
|
|
|
|
2024-06-17 22:48:15 -04:00
|
|
|
rom = generator.generateRom(args, self)
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-04-14 22:47:45 -07:00
|
|
|
with open(out_path, "wb") as handle:
|
|
|
|
rom.save(handle, name="LADXR")
|
|
|
|
|
|
|
|
# Write title screen after everything else is done - full gfxmods may stomp over the egg tiles
|
2024-06-17 22:48:15 -04:00
|
|
|
if self.options.ap_title_screen:
|
2023-04-14 22:47:45 -07:00
|
|
|
with tempfile.NamedTemporaryFile(delete=False) as title_patch:
|
|
|
|
title_patch.write(pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4"))
|
2025-03-07 19:24:58 -05:00
|
|
|
|
2023-04-14 22:47:45 -07:00
|
|
|
bsdiff4.file_patch_inplace(out_path, title_patch.name)
|
|
|
|
os.unlink(title_patch.name)
|
|
|
|
|
2023-04-11 00:18:33 -07:00
|
|
|
patch = LADXDeltaPatch(os.path.splitext(out_path)[0]+LADXDeltaPatch.patch_file_ending, player=self.player,
|
2024-06-17 22:48:15 -04:00
|
|
|
player_name=self.player_name, patched_path=out_path)
|
2023-03-21 01:26:03 +09:00
|
|
|
patch.write()
|
|
|
|
if not DEVELOPER_MODE:
|
2023-04-11 00:18:33 -07:00
|
|
|
os.unlink(out_path)
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
def generate_multi_key(self):
|
2024-06-17 22:48:15 -04:00
|
|
|
return bytearray(self.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big')
|
2023-03-21 01:26:03 +09:00
|
|
|
|
|
|
|
def modify_multidata(self, multidata: dict):
|
2024-06-17 22:48:15 -04:00
|
|
|
multidata["connect_names"][binascii.hexlify(self.multi_key).decode()] = multidata["connect_names"][self.player_name]
|
2023-05-29 01:17:30 +02:00
|
|
|
|
|
|
|
def collect(self, state, item: Item) -> bool:
|
|
|
|
change = super().collect(state, item)
|
2023-11-25 15:07:02 -06:00
|
|
|
if change and item.name in self.rupees:
|
|
|
|
state.prog_items[self.player]["RUPEES"] += self.rupees[item.name]
|
2023-05-29 01:17:30 +02:00
|
|
|
return change
|
|
|
|
|
|
|
|
def remove(self, state, item: Item) -> bool:
|
|
|
|
change = super().remove(state, item)
|
2023-11-25 15:07:02 -06:00
|
|
|
if change and item.name in self.rupees:
|
|
|
|
state.prog_items[self.player]["RUPEES"] -= self.rupees[item.name]
|
2023-05-29 01:17:30 +02:00
|
|
|
return change
|
2024-09-17 23:56:40 +02:00
|
|
|
|
2025-01-15 21:42:19 -05:00
|
|
|
# Same fill choices and weights used in LADXR.itempool.__randomizeRupees
|
|
|
|
filler_choices = ("Bomb", "Single Arrow", "10 Arrows", "Magic Powder", "Medicine")
|
|
|
|
filler_weights = ( 10, 5, 10, 10, 1)
|
|
|
|
|
2024-09-20 10:18:09 -04:00
|
|
|
def get_filler_item_name(self) -> str:
|
2025-01-15 21:42:19 -05:00
|
|
|
if self.options.stabilize_item_pool:
|
|
|
|
return "Nothing"
|
|
|
|
return self.random.choices(self.filler_choices, self.filler_weights)[0]
|
2024-09-20 10:18:09 -04:00
|
|
|
|
2024-09-17 23:56:40 +02:00
|
|
|
def fill_slot_data(self):
|
|
|
|
slot_data = {}
|
|
|
|
|
|
|
|
if not self.multiworld.is_race:
|
|
|
|
# all of these option are NOT used by the LADX- or Text-Client.
|
|
|
|
# they are used by Magpie tracker (https://github.com/kbranch/Magpie/wiki/Autotracker-API)
|
|
|
|
# for convenient auto-tracking of the generated settings and adjusting the tracker accordingly
|
|
|
|
|
|
|
|
slot_options = ["instrument_count"]
|
|
|
|
|
|
|
|
slot_options_display_name = [
|
2024-12-19 21:19:00 -05:00
|
|
|
"goal",
|
|
|
|
"logic",
|
|
|
|
"tradequest",
|
|
|
|
"rooster",
|
|
|
|
"experimental_dungeon_shuffle",
|
|
|
|
"experimental_entrance_shuffle",
|
|
|
|
"trendy_game",
|
|
|
|
"gfxmod",
|
|
|
|
"shuffle_nightmare_keys",
|
|
|
|
"shuffle_small_keys",
|
|
|
|
"shuffle_maps",
|
|
|
|
"shuffle_compasses",
|
|
|
|
"shuffle_stone_beaks",
|
|
|
|
"shuffle_instruments",
|
|
|
|
"nag_messages",
|
|
|
|
"hard_mode",
|
2024-12-20 07:55:32 -05:00
|
|
|
"overworld",
|
2024-09-17 23:56:40 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
# use the default behaviour to grab options
|
|
|
|
slot_data = self.options.as_dict(*slot_options)
|
|
|
|
|
|
|
|
# for options which should not get the internal int value but the display name use the extra handling
|
|
|
|
slot_data.update({
|
|
|
|
option: value.current_key
|
|
|
|
for option, value in dataclasses.asdict(self.options).items() if option in slot_options_display_name
|
|
|
|
})
|
|
|
|
|
2025-04-05 12:39:31 -04:00
|
|
|
slot_data.update({"entrance_mapping": self.ladxr_logic.world_setup.entrance_mapping})
|
|
|
|
|
2024-09-17 23:56:40 +02:00
|
|
|
return slot_data
|