Files
Grinch-AP/worlds/ladx/__init__.py

434 lines
20 KiB
Python
Raw Normal View History

import binascii
import bsdiff4
import os
import pkgutil
import tempfile
from BaseClasses import Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
from .Common import *
from .Items import (DungeonItemData, DungeonItemType, LinksAwakeningItem,
ladxr_item_to_la_item_name, links_awakening_items,
links_awakening_items_by_name)
from .LADXR import generator
from .LADXR.itempool import ItemPool as LADXRItemPool
from .LADXR.locations.tradeSequence import TradeSequenceItem
from .LADXR.logic import Logic as LAXDRLogic
from .LADXR.main import get_parser
from .LADXR.settings import Settings as LADXRSettings
from .LADXR.worldSetup import WorldSetup as LADXRWorldSetup
from .LADXR.locations.instrument import Instrument
from .LADXR.locations.constants import CHEST_ITEMS
from .Locations import (LinksAwakeningLocation, LinksAwakeningRegion,
create_regions_from_ladxr, get_locations_to_id)
from .Options import links_awakening_options
from .Rom import LADXDeltaPatch
DEVELOPER_MODE = False
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"
class LinksAwakeningWorld(World):
"""
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!
"""
game: str = LINKS_AWAKENING # name of the game/world
web = LinksAwakeningWebWorld()
option_definitions = links_awakening_options # options the player can set
topology_present = True # show path to required location checks in spoiler
# data_version is used to signal that items, locations or their names
# changed. Set this to 0 during development so other games' clients do not
# cache any texts, then increase by 1 for each release that makes changes.
data_version = 1
# 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
#item_name_groups = {
# "weapons": {"sword", "lance"}
#}
prefill_dungeon_items = None
player_options = None
def convert_ap_options_to_ladxr_logic(self):
self.player_options = {
option: getattr(self.multiworld, option)[self.player] for option in self.option_definitions
}
self.laxdr_options = LADXRSettings(self.player_options)
self.laxdr_options.validate()
world_setup = LADXRWorldSetup()
world_setup.randomize(self.laxdr_options, self.multiworld.random)
self.ladxr_logic = LAXDRLogic(configuration_options=self.laxdr_options, world_setup=world_setup)
self.ladxr_itempool = LADXRItemPool(self.ladxr_logic, self.laxdr_options, self.multiworld.random).toDict()
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)
menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld)
menu_region.exits = [Entrance(self.player, "Start Game", menu_region)]
menu_region.exits[0].connect(start)
self.multiworld.regions.append(menu_region)
# Place RAFT, other access events
for region in regions:
for loc in region.locations:
if loc.event:
loc.place_locked_item(self.create_event(loc.ladxr_item.event))
# Connect Windfish -> Victory
windfish = self.multiworld.get_region("Windfish", self.player)
l = Location(self.player, "Windfish", parent=windfish)
windfish.locations = [l]
l.place_locked_item(self.create_event("An Alarm Clock"))
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)
def create_items(self) -> None:
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
self.trade_items = []
dungeon_item_types = {
}
from .Options import DungeonItemShuffle
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
self.prefill_own_dungeons = []
# For any and different world, set item rule instead
for option in ["maps", "compasses", "small_keys", "nightmare_keys", "stone_beaks"]:
option = "shuffle_" + option
option = self.player_options[option]
dungeon_item_types[option.ladxr_item] = option.value
if option.value == DungeonItemShuffle.option_own_world:
self.multiworld.local_items[self.player].value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, 10)
}
elif option.value == DungeonItemShuffle.option_different_world:
self.multiworld.non_local_items[self.player].value |= {
ladxr_item_to_la_item_name[f"{option.ladxr_item}{i}"] for i in range(1, 10)
}
# 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
self.multiworld.itempool.append(self.create_item("Master Stalfos' Message"))
else:
item = self.create_item(item_name)
if not self.multiworld.tradequest[self.player] and ladx_item_name.startswith("TRADING_"):
self.trade_items.append(item)
continue
if isinstance(item.item_data, DungeonItemData):
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT:
# Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False
for r in self.multiworld.get_regions():
if r.player != self.player:
continue
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:
break
else:
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = dungeon_item_types[item_type]
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
else:
self.multiworld.itempool.append(item)
else:
self.multiworld.itempool.append(item)
def pre_fill(self):
self.multi_key = self.generate_multi_key()
dungeon_locations = []
dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
all_state = self.multiworld.get_all_state(use_cache=False)
# 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"))
# For now, special case first item
FORCE_START_ITEM = True
if FORCE_START_ITEM:
start_loc = self.multiworld.get_location("Tarin's Gift (Mabe Village)", self.player)
if not start_loc.item:
possible_start_items = [index for index, item in enumerate(self.multiworld.itempool)
if item.player == self.player
and item.item_data.ladxr_id in start_loc.ladxr_item.OPTIONS]
index = self.multiworld.random.choice(possible_start_items)
start_item = self.multiworld.itempool.pop(index)
start_loc.place_locked_item(start_item)
for r in self.multiworld.get_regions():
if r.player != self.player:
continue
# Set aside dungeon locations
if r.dungeon_index:
dungeon_locations += r.locations
dungeon_locations_by_dungeon[r.dungeon_index - 1] += r.locations
for location in r.locations:
if location.name == "Pit Button Chest (Tail Cave)":
# 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
dungeon_locations.remove(location)
dungeon_locations_by_dungeon[r.dungeon_index - 1].remove(location)
# Properly fill locations within dungeon
location.dungeon = r.dungeon_index
# Tell the filler that if we're placing a dungeon item, restrict it to the dungeon the item associates with
# This will need changed once keysanity is implemented
#orig_rule = location.item_rule
#location.item_rule = lambda item, orig_rule=orig_rule: \
# (not isinstance(item, DungeonItemData) or item.dungeon_index == location.dungeon) and orig_rule(item)
for location in r.locations:
# If tradequests are disabled, place trade items directly in their proper location
if not self.multiworld.tradequest[self.player] and isinstance(location, LinksAwakeningLocation) and isinstance(location.ladxr_item, TradeSequenceItem):
item = next(i for i in self.trade_items if i.item_data.ladxr_id == location.ladxr_item.default_item)
location.place_locked_item(item)
for dungeon_index in range(0, 9):
locs = dungeon_locations_by_dungeon[dungeon_index]
locs = [loc for loc in locs if not loc.item]
self.multiworld.random.shuffle(locs)
self.multiworld.random.shuffle(self.prefill_original_dungeon[dungeon_index])
fill_restrictive(self.multiworld, all_state, locs, self.prefill_original_dungeon[dungeon_index], lock=True)
assert not self.prefill_original_dungeon[dungeon_index]
# Fill dungeon items first, to not torture the fill algo
dungeon_locations = [loc for loc in dungeon_locations if not loc.item]
# dungeon_items = sorted(self.prefill_own_dungeons, key=lambda item: item.item_data.dungeon_item_type)
self.multiworld.random.shuffle(self.prefill_own_dungeons)
self.multiworld.random.shuffle(dungeon_locations)
fill_restrictive(self.multiworld, all_state, dungeon_locations, self.prefill_own_dungeons, lock=True)
name_cache = {}
# Tries to associate an icon from another game with an icon we have
def guess_icon_for_other_world(self, other):
if not self.name_cache:
forbidden = [
"TRADING",
"ITEM",
"BAD",
"SINGLE",
"UPGRADE",
"BLUE",
"RED",
"NOTHING",
"MESSAGE",
]
for item in ladxr_item_to_la_item_name.keys():
self.name_cache[item] = item
splits = item.split("_")
self.name_cache["".join(splits)] = item
if 'RUPEES' in splits:
self.name_cache["".join(reversed(splits))] = item
for word in item.split("_"):
if word not in forbidden and not word.isnumeric():
self.name_cache[word] = item
others = {
'KEY': 'KEY',
'COMPASS': 'COMPASS',
'BIGKEY': 'NIGHTMARE_KEY',
'MAP': 'MAP',
'FLUTE': 'OCARINA',
'SONG': 'OCARINA',
'MUSHROOM': 'TOADSTOOL',
'GLOVE': 'POWER_BRACELET',
'BOOT': 'PEGASUS_BOOTS',
'SHOE': 'PEGASUS_BOOTS',
'SHOES': 'PEGASUS_BOOTS',
'SANCTUARYHEARTCONTAINER': 'HEART_CONTAINER',
'BOSSHEARTCONTAINER': 'HEART_CONTAINER',
'HEARTCONTAINER': 'HEART_CONTAINER',
'ENERGYTANK': 'HEART_CONTAINER',
'MISSILE': 'SINGLE_ARROW',
'BOMBS': 'BOMB',
'BLUEBOOMERANG': 'BOOMERANG',
'MAGICMIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MIRROR': 'TRADING_ITEM_MAGNIFYING_GLASS',
'MESSAGE': 'TRADING_ITEM_LETTER',
# TODO: Also use AP item name
}
for name in others.values():
assert name in self.name_cache, name
assert name in CHEST_ITEMS, name
self.name_cache.update(others)
uppered = other.upper()
if "BIG KEY" in uppered:
return 'NIGHTMARE_KEY'
possibles = other.upper().split(" ")
rejoined = "".join(possibles)
if rejoined in self.name_cache:
return self.name_cache[rejoined]
for name in possibles:
if name in self.name_cache:
return self.name_cache[name]
return "TRADING_ITEM_LETTER"
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)
# 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
# TODO: if the item name contains "sword", use a sword icon, etc
# Otherwise, use a cute letter as the icon
else:
loc.ladxr_item.item = self.guess_icon_for_other_world(loc.item.name)
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-04-11 00:18:33 -07:00
rom_name = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.multiworld.player_name[self.player]}.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")
parser = get_parser()
2023-04-11 00:18:33 -07:00
args = parser.parse_args([rom_name, "-o", out_name, "--dump"])
name_for_rom = self.multiworld.player_name[self.player]
all_names = [self.multiworld.player_name[i + 1] for i in range(len(self.multiworld.player_name))]
rom = generator.generateRom(
args,
self.laxdr_options,
self.player_options,
self.multi_key,
self.multiworld.seed_name,
self.ladxr_logic,
rnd=self.multiworld.per_slot_randoms[self.player],
player_name=name_for_rom,
player_names=all_names,
player_id = self.player)
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
if self.player_options["ap_title_screen"]:
with tempfile.NamedTemporaryFile(delete=False) as title_patch:
title_patch.write(pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4"))
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,
player_name=self.multiworld.player_name[self.player], patched_path=out_path)
patch.write()
if not DEVELOPER_MODE:
2023-04-11 00:18:33 -07:00
os.unlink(out_path)
def generate_multi_key(self):
return bytearray(self.multiworld.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big')
def modify_multidata(self, multidata: dict):
multidata["connect_names"][binascii.hexlify(self.multi_key).decode()] = multidata["connect_names"][self.multiworld.player_name[self.player]]