Merge branch 'ArchipelagoMW:main' into Satisfactory_ToBeVerified

This commit is contained in:
Jarno
2025-07-27 19:57:37 +02:00
committed by GitHub
50 changed files with 1276 additions and 527 deletions

View File

@@ -19,7 +19,12 @@ on:
env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation
id-token: 'write'
@@ -134,10 +139,13 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |

View File

@@ -7,11 +7,11 @@ import random
import secrets
import warnings
from argparse import Namespace
from collections import Counter, deque
from collections import Counter, deque, defaultdict
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -585,26 +585,9 @@ class MultiWorld():
if self.has_beaten_game(state):
return True
base_locations = self.get_locations() if locations is None else locations
prog_locations = {location for location in base_locations if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
state.collect(location.item, True, location)
prog_locations -= sphere
for _ in state.sweep_for_advancements(locations,
yield_each_sweep=True,
checked_locations=state.locations_checked):
if self.has_beaten_game(state):
return True
@@ -889,20 +872,133 @@ class CollectionState():
"Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations)
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_advancements = True
# since the loop has a good chance to run more than once, only filter the advancements once
locations = {location for location in locations if location.advancement and location not in self.advancements}
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
yield_each_sweep: bool) -> Iterator[None]:
"""
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
of a yield statement.
"""
all_players = {player for player, _ in advancements_per_player}
players_to_check = all_players
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
# sweep is finished.
checking_if_finished = False
while players_to_check:
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
next_players_to_check = set()
while reachable_advancements:
reachable_advancements = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements
for advancement in reachable_advancements:
self.advancements.add(advancement)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement)
for player, locations in advancements_per_player:
if player not in players_to_check:
next_advancements_per_player.append((player, locations))
continue
# Accessibility of each location is checked first because a player's region accessibility cache becomes
# stale whenever one of their own items is collected into the state.
reachable_locations: List[Location] = []
unreachable_locations: List[Location] = []
for location in locations:
if location.can_reach(self):
# Locations containing items that do not belong to `player` could be collected immediately
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
# items at reachable locations are collected in a single loop.
reachable_locations.append(location)
else:
unreachable_locations.append(location)
if unreachable_locations:
next_advancements_per_player.append((player, unreachable_locations))
# A previous player's locations processed in the current `while players_to_check` iteration could have
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
# their items is collected.
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
# more performant to instead discard `player` from `next_players_to_check` once their locations have
# been processed.
next_players_to_check.discard(player)
# Collect the items from the reachable locations.
for advancement in reachable_locations:
self.advancements.add(advancement)
item = advancement.item
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
if self.collect(item, True, advancement):
# The player the item belongs to may be able to reach additional locations in the next sweep
# iteration.
next_players_to_check.add(item.player)
if not next_players_to_check:
if not checking_if_finished:
# It is assumed that each player's world only logically depends on itself, which may not be the
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
checking_if_finished = True
next_players_to_check = all_players
else:
checking_if_finished = False
players_to_check = next_players_to_check
advancements_per_player = next_advancements_per_player
if yield_each_sweep:
yield
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
yield_each_sweep: Literal[True],
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
yield_each_sweep: Literal[False] = False,
checked_locations: Optional[Set[Location]] = None) -> None: ...
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
"""
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
until there are no more reachable locations that contain uncollected advancement items.
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
self.advancements when None.
"""
if checked_locations is None:
checked_locations = self.advancements
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
advancements_per_player: List[Tuple[int, List[Location]]]
if locations is None:
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
advancements_per_player = []
for player, locations_dict in self.multiworld.regions.location_cache.items():
filtered_locations = [location for location in locations_dict.values()
if location.advancement and location not in checked_locations]
if filtered_locations:
advancements_per_player.append((player, filtered_locations))
else:
# Filter and separate the locations into a list for each player.
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
for location in locations:
if location.advancement and location not in checked_locations:
advancements_per_player_dict[location.player].append(location)
# Convert to a list of tuples.
advancements_per_player = list(advancements_per_player_dict.items())
del advancements_per_player_dict
if yield_each_sweep:
# Return a generator that will yield at the end of each sweep iteration.
return self._sweep_for_advancements_impl(advancements_per_player, True)
else:
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
# once started, then start and exhaust the generator by attempting to iterate it.
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
assert False, "Generator yielded when it should have run to completion without yielding"
return None
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:

View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from MultiServer import CommandProcessor, mark_raw
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start
@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True
def get_current_datapackage(self) -> dict[str, typing.Any]:
"""
Return datapackage for current game if known.
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
"""
if not self.ctx.game:
return {}
checksum = self.ctx.checksums[self.ctx.game]
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state.
Can be given text, which will be used as filter."""
@@ -107,7 +118,9 @@ class ClientCommandProcessor(CommandProcessor):
return False
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
lookup = self.get_current_datapackage().get("location_name_to_id", {})
for location, location_id in lookup.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -128,43 +141,91 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_items(self):
def output_datapackage_part(self, key: str, name: str) -> bool:
"""
Helper to digest a specific section of this game's datapackage.
:param key: The dictionary key in the datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.get_current_datapackage().get(key)
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
self.output(f"{name} for {self.ctx.game}")
for key in lookup:
self.output(key)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
return self.output_datapackage_part("item_name_to_id", "Item Names")
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self):
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
return self.output_datapackage_part("location_name_to_id", "Location Names")
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
name: str) -> bool:
"""
Logs an item or location group from the player's game's datapackage.
def _cmd_ready(self):
:param group_key: Either Item or Location group to be processed.
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.")
return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
.get(self.ctx.game, {}).get(group_key, {})
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
if filter_key:
if filter_key not in lookup:
self.output(f"Unknown {name} Group {filter_key}")
return False
self.output(f"{name}s for {name} Group \"{filter_key}\"")
for entry in lookup[filter_key]:
self.output(entry)
else:
self.output(f"{name} Groups for {self.ctx.game}")
for group in lookup:
self.output(group)
return True
@mark_raw
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@@ -174,6 +235,7 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
@@ -379,6 +441,8 @@ class CommonContext:
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package)
# execution
@@ -638,6 +702,24 @@ class CommonContext:
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage
def set_notify(self, *keys: str) -> None:
@@ -938,6 +1020,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -1018,11 +1106,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:

View File

@@ -86,7 +86,7 @@ COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp /tmp/EnemizerCLI EnemizerCLI; \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
@@ -94,5 +94,7 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

47
Main.py
View File

@@ -1,10 +1,11 @@
import collections
from collections.abc import Mapping
import concurrent.futures
import logging
import os
import pickle
import tempfile
import time
from typing import Any
import zipfile
import zlib
@@ -14,7 +15,7 @@ from Fill import FillError, balance_multiworld_progression, distribute_items_res
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from NetUtils import convert_to_base_types
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple
from Utils import __version__, output_path, restricted_dumps, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -93,6 +94,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
del local_early
del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions")
@@ -100,12 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids:
@@ -126,11 +130,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1:
locality_rules(multiworld)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
@@ -239,11 +241,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
def write_multidata():
import NetUtils
from NetUtils import HintStatus
slot_data = {}
client_versions = {}
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
slot_data: dict[int, Mapping[str, Any]] = {}
client_versions: dict[int, tuple[int, int, int]] = {}
games: dict[int, str] = {}
minimum_versions: NetUtils.MinimumVersions = {
"server": AutoWorld.World.required_server_version, "clients": client_versions
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -258,7 +262,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
precollected_hints: dict[int, set[NetUtils.Hint]] = {
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
@@ -315,7 +321,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = {
multidata: NetUtils.MultiData | bytes = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -325,7 +331,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(version_tuple),
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name,
@@ -333,12 +339,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(pickle.dumps(multidata), 9)
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format

View File

@@ -43,7 +43,7 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, Hint, HintStatus
SlotType, LocationStore, MultiData, Hint, HintStatus
from BaseClasses import ItemClassification
@@ -445,7 +445,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
@@ -546,6 +546,7 @@ class Context:
def _save(self, exit_save: bool = False) -> bool:
try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
@@ -752,7 +753,7 @@ class Context:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None):
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -767,8 +768,9 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# only remember hints that were not already found at the time of creation
if not hint.found:
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
# For LocationScouts use-cases, all hints should be remembered
if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
@@ -1946,7 +1948,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
@@ -1990,7 +1992,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True)
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint':

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing
import enum
import warnings
@@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
group_members: Sequence[int] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
@@ -471,6 +472,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:

View File

@@ -1644,7 +1644,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
"""
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to

View File

@@ -483,6 +483,18 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue:
"""
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.

View File

@@ -1,11 +1,11 @@
import json
import pickle
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400
else:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])

View File

@@ -164,9 +164,6 @@ def autogen(config: dict):
Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, config: dict, id: int):
self.room_ids = set()

View File

@@ -1,7 +1,7 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
from collections.abc import Set
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
@@ -43,7 +43,7 @@ def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
def get_yaml_data(files) -> dict[str, str] | str | Markup:
options = {}
for uploaded_file in files:
if banned_file(uploaded_file.filename):
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
return options
def roll_options(options: Dict[str, Union[dict, str]],
def roll_options(options: dict[str, dict | str],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
tuple[dict[str, str | bool], dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options))
results = {}
rolled_results = {}
results: dict[str, str | bool] = {}
rolled_results: dict[str, dict] = {}
for filename, text in options.items():
try:
if type(text) is dict:

View File

@@ -129,7 +129,7 @@ class WebHostContext(Context):
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
game_data_packages[game] = restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
@@ -159,6 +159,7 @@ class WebHostContext(Context):
@db_session
def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id)
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again

View File

@@ -1,12 +1,12 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
from collections import Counter
from typing import Any, Dict, List, Optional, Union, Set
from pickle import PicklingError
from typing import Any
from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__
from Utils import __version__, restricted_dumps
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
@@ -23,8 +23,8 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: Set[str] = set()
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
plando_options: set[str] = set()
for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr)
@@ -73,7 +73,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
@@ -83,12 +83,18 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
try:
gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
commit()
return redirect(url_for("wait_seed", seed=gen.id))
@@ -104,9 +110,9 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
if meta is None:
meta = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)

View File

@@ -1,4 +1,3 @@
import typing
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
@@ -18,21 +17,23 @@ from .models import Room
PLOT_WIDTH = 600
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
total_games: Counter[str] = Counter()
cutoff = date.today() - timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
if slot.game in known_games:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
current_game = slot.game
else:
current_game = "Other"
total_games[current_game] += 1
games_played[room.creation_time.date()][current_game] += 1
return total_games, games_played
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
def get_color_palette(colors_needed: int) -> list[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
return colors
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
@@ -84,7 +84,7 @@ def stats():
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games):
occurences = []

View File

@@ -1,4 +1,3 @@
import base64
import json
import pickle
import typing
@@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError
import schema
import MultiServer
from NetUtils import SlotType
from NetUtils import GamesPackage, SlotType
from Utils import VersionException, __version__
from worlds import GamesPackage
from worlds.Files import AutoPatchRegister
from worlds.AutoWorld import data_package_checksum
from . import app

View File

@@ -9,9 +9,10 @@ Follow these steps to build and deploy a containerized instance of the web host
What you'll need:
* A container runtime engine such as:
* [Docker](https://www.docker.com/)
* [Podman](https://podman.io/)
* [Docker](https://www.docker.com/) (Version 23.0 or later)
* [Podman](https://podman.io/) (version 4.0 or later)
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
`docker build -t archipelago .`

View File

@@ -340,7 +340,8 @@ Sent to the server to retrieve the items that are on a specified list of locatio
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code.
#### Arguments
| Name | Type | Notes |

View File

@@ -515,6 +515,7 @@ In addition, the following methods can be implemented and are called in this ord
called per player before any items or locations are created. You can set properties on your
world here. Already has access to player options and RNG. This is the earliest step where the world should start
setting up for the current multiworld, as the multiworld itself is still setting up before this point.
You cannot modify `local_items`, or `non_local_items` after this step.
* `create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list.
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
@@ -538,7 +539,7 @@ In addition, the following methods can be implemented and are called in this ord
creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
item. `location.item.player` can be used to see if it's a local item.
* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
* `fill_slot_data(self)` and `modify_multidata(self, multidata: MultiData)` can be used to modify the data that
will be used by the server to host the MultiWorld.
All instance methods can, optionally, have a class method defined which will be called after all instance methods are

View File

@@ -52,13 +52,15 @@ class EntranceLookup:
_coupled: bool
_usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance], targets: Iterable[Entrance]):
self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup()
self._random = rng
self._expands_graph_cache = {}
self._coupled = coupled
self._usable_exits = usable_exits
for target in targets:
self.add(target)
def _can_expand_graph(self, entrance: Entrance) -> bool:
"""
@@ -121,7 +123,14 @@ class EntranceLookup:
dead_end: bool,
preserve_group_order: bool
) -> Iterable[Entrance]:
"""
Gets available targets for the requested groups
:param groups: The groups to find targets for
:param dead_end: Whether to find dead ends. If false, finds non-dead-ends
:param preserve_group_order: Whether to preserve the group order in the returned iterable. If true, a sequence
like AAABBB is guaranteed. If false, groups can be interleaved, e.g. BAABAB.
"""
lookup = self.dead_ends if dead_end else self.others
if preserve_group_order:
for group in groups:
@@ -132,6 +141,27 @@ class EntranceLookup:
self._random.shuffle(ret)
return ret
def find_target(self, name: str, group: int | None = None, dead_end: bool | None = None) -> Entrance | None:
"""
Finds a specific target in the lookup, if it is present.
:param name: The name of the target
:param group: The target's group. Providing this will make the lookup faster, but can be omitted if it is not
known ahead of time for some reason.
:param dead_end: Whether the target is a dead end. Providing this will make the lookup faster, but can be
omitted if this is not known ahead of time (much more likely)
"""
if dead_end is None:
return (found
if (found := self.find_target(name, group, True))
else self.find_target(name, group, False))
lookup = self.dead_ends if dead_end else self.others
targets_to_check = lookup if group is None else lookup[group]
for target in targets_to_check:
if target.name == name:
return target
return None
def __len__(self):
return len(self.dead_ends) + len(self.others)
@@ -146,15 +176,18 @@ class ERPlacementState:
"""The world which is having its entrances randomized"""
collection_state: CollectionState
"""The CollectionState backing the entrance randomization logic"""
entrance_lookup: EntranceLookup
"""A lookup table of all unconnected ER targets"""
coupled: bool
"""Whether entrance randomization is operating in coupled mode"""
def __init__(self, world: World, coupled: bool):
def __init__(self, world: World, entrance_lookup: EntranceLookup, coupled: bool):
self.placements = []
self.pairings = []
self.world = world
self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup
@property
def placed_regions(self) -> set[Region]:
@@ -182,6 +215,7 @@ class ERPlacementState:
self.collection_state.stale[self.world.player] = True
self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name))
self.entrance_lookup.remove(target_entrance)
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool:
@@ -311,7 +345,7 @@ def randomize_entrances(
preserve_group_order: bool = False,
er_targets: list[Entrance] | None = None,
exits: list[Entrance] | None = None,
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
on_connect: Callable[[ERPlacementState, list[Entrance], list[Entrance]], bool | None] | None = None
) -> ERPlacementState:
"""
Randomizes Entrances for a single world in the multiworld.
@@ -328,14 +362,18 @@ def randomize_entrances(
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
:param on_connect: A callback function which allows specifying side effects after a placement is completed
successfully and the underlying collection state has been updated.
successfully and the underlying collection state has been updated. The arguments are
1. The ER state
2. The exits placed in this placement pass
3. The entrances they were connected to.
If you use on_connect to make additional placements, you are expected to return True to inform
GER that an additional sweep is needed.
"""
if not world.explicit_indirect_conditions:
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
+ "to correctly analyze whether dead end regions can be required in logic.")
start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True
@@ -351,23 +389,25 @@ def randomize_entrances(
# used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits)
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
for entrance in er_targets:
entrance_lookup.add(entrance)
er_state = ERPlacementState(
world,
EntranceLookup(world.random, coupled, exits_set, er_targets),
coupled
)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration
for entrance in removed_entrances:
entrance_lookup.remove(entrance)
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
# propagate new connections
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
if on_connect:
on_connect(er_state, placed_exits)
change = on_connect(er_state, placed_exits, paired_entrances)
if change:
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
@@ -388,12 +428,12 @@ def randomize_entrances(
# check to see if we are proposing the last placement
if not coupled:
# in uncoupled, this check is easy as there will only be one target.
is_last_placement = len(entrance_lookup) == 1
is_last_placement = len(er_state.entrance_lookup) == 1
else:
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
is_last_placement = len(entrance_lookup) == desired_target_count
is_last_placement = len(er_state.entrance_lookup) == desired_target_count
# if it's not the last placement, we need a sweep
return not is_last_placement
@@ -402,7 +442,7 @@ def randomize_entrances(
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
for target_entrance in er_state.entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
# when requiring new exits, ideally we would like to make it so that every placement increases
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
@@ -420,7 +460,7 @@ def randomize_entrances(
else:
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
# deadlocking is a frequent issue.
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
lookup = er_state.entrance_lookup.dead_ends if dead_end else er_state.entrance_lookup.others
# if we're in a stage where we're trying to get to new regions, we could also enter this
# branch in a success state (when all regions of the preferred type have been placed, but there are still
@@ -466,21 +506,21 @@ def randomize_entrances(
f"All unplaced exits: {unplaced_exits}")
# stage 1 - try to place all the non-dead-end entrances
while entrance_lookup.others:
while er_state.entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True):
break
# stage 2 - try to place all the dead-end entrances
while entrance_lookup.dead_ends:
while er_state.entrance_lookup.dead_ends:
if not find_pairing(dead_end=True, require_new_exits=True):
break
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
# doing this before the non-dead-ends is important to ensure there are enough connections to
# go around
while entrance_lookup.dead_ends:
while er_state.entrance_lookup.dead_ends:
find_pairing(dead_end=True, require_new_exits=False)
# stage 3b - tie all the other loose ends connecting visited regions to each other
while entrance_lookup.others:
while er_state.entrance_lookup.others:
find_pairing(dead_end=False, require_new_exits=False)
running_time = time.perf_counter() - start_time

View File

@@ -16,6 +16,10 @@ from collections.abc import Iterable, Sequence
from hashlib import sha3_512
from pathlib import Path
SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fixed
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
requirement = 'cx-Freeze==8.0.0'
try:
@@ -89,7 +93,8 @@ def download_SNI() -> None:
machine_name = platform.machine().lower()
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
sni_version_ref = "latest" if SNI_VERSION == "latest" else f"tags/{SNI_VERSION}"
with urllib.request.urlopen(f"https://api.github.com/repos/alttpo/SNI/releases/{sni_version_ref}") as request:
data = json.load(request)
files = data["assets"]

View File

@@ -69,11 +69,9 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, False)
@@ -92,11 +90,9 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, True)
@@ -112,12 +108,10 @@ class TestEntranceLookup(unittest.TestCase):
for ex in region.exits if not ex.connected_region
and ex.name != "region20_right" and ex.name != "region21_left"])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region and
entrance.name != "region20_right" and entrance.name != "region21_left"]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
# the top entrance from region 15 should be considered a dead-end
@@ -129,6 +123,56 @@ class TestEntranceLookup(unittest.TestCase):
self.assertTrue(dead_end in lookup.dead_ends)
self.assertEqual(len(lookup.dead_ends), 1)
def test_find_target_by_name(self):
"""Tests that find_target can find the correct target by name only"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right")
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
self.assertIsNone(lookup.find_target("nonexistant"))
def test_find_target_by_name_and_group(self):
"""Tests that find_target can find the correct target by name and group"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong group
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.LEFT))
def test_find_target_by_name_and_group_and_category(self):
"""Tests that find_target can find the correct target by name, group, and dead-endedness"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT, False)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong deadendedness
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.RIGHT, True))
class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self):
multiworld = generate_test_multiworld()
@@ -265,12 +309,12 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
def verify_coupled(_: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(2, len(placed_entrances))
self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
seen_placement_count += len(placed_exits)
self.assertEqual(2, len(placed_exits))
self.assertEqual(placed_exits[0].parent_region, placed_exits[1].connected_region)
self.assertEqual(placed_exits[1].parent_region, placed_exits[0].connected_region)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
on_connect=verify_coupled)
@@ -313,10 +357,10 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
def verify_uncoupled(state: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(1, len(placed_entrances))
seen_placement_count += len(placed_exits)
self.assertEqual(1, len(placed_exits))
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
on_connect=verify_uncoupled)

View File

@@ -148,8 +148,8 @@ class TestBase(unittest.TestCase):
def test_locality_not_modified(self):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):

View File

@@ -1,7 +1,8 @@
import unittest
from BaseClasses import MultiWorld, PlandoOptions
from Options import ItemLinks
from BaseClasses import PlandoOptions
from Options import ItemLinks, Choice
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -73,9 +74,10 @@ class TestOptions(unittest.TestCase):
def test_pickle_dumps(self):
"""Test options can be pickled into database for WebHost generation"""
import pickle
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
pickle.dumps(option.from_any(option.default))
restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default]))

View File

@@ -16,7 +16,7 @@ from Utils import deprecate
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
from . import GamesPackage
from NetUtils import GamesPackage, MultiData
from settings import Group
perf_logger = logging.getLogger("performance")
@@ -450,7 +450,7 @@ class World(metaclass=AutoWorldRegister):
"""
pass
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
def modify_multidata(self, multidata: "MultiData") -> None:
"""For deeper modification of server multidata."""
pass

View File

@@ -15,7 +15,6 @@ import bsdiff4
semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading
del os
class AutoPatchRegister(abc.ABCMeta):
@@ -34,10 +33,8 @@ class AutoPatchRegister(abc.ABCMeta):
@staticmethod
def get_handler(file: str) -> Optional[AutoPatchRegister]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
return None
_, suffix = os.path.splitext(file)
return AutoPatchRegister.file_endings.get(suffix, None)
class AutoPatchExtensionRegister(abc.ABCMeta):

View File

@@ -7,8 +7,9 @@ import warnings
import zipimport
import time
import dataclasses
from typing import Dict, List, TypedDict
from typing import List
from NetUtils import DataPackage
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
@@ -24,8 +25,6 @@ __all__ = {
"world_sources",
"local_folder",
"user_folder",
"GamesPackage",
"DataPackage",
"failed_world_loads",
}
@@ -33,18 +32,6 @@ __all__ = {
failed_world_loads: List[str] = []
class GamesPackage(TypedDict, total=False):
item_name_groups: Dict[str, List[str]]
item_name_to_id: Dict[str, int]
location_name_groups: Dict[str, List[str]]
location_name_to_id: Dict[str, int]
checksum: str
class DataPackage(TypedDict):
games: Dict[str, GamesPackage]
@dataclasses.dataclass(order=True)
class WorldSource:
path: str # typically relative path from this module

View File

@@ -4,6 +4,7 @@ checking or launching the client, otherwise it will probably cause circular impo
"""
import asyncio
import copy
import enum
import subprocess
from typing import Any
@@ -13,7 +14,7 @@ import Patch
import Utils
from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \
get_script_version, get_system, ping
get_script_version, get_system, ping, display_message
from .client import BizHawkClient, AutoBizHawkClientRegister
@@ -27,20 +28,97 @@ class AuthStatus(enum.IntEnum):
AUTHENTICATED = 3
class TextCategory(str, enum.Enum):
ALL = "all"
INCOMING = "incoming"
OUTGOING = "outgoing"
OTHER = "other"
HINT = "hint"
CHAT = "chat"
SERVER = "server"
class BizHawkClientCommandProcessor(ClientCommandProcessor):
def _cmd_bh(self):
"""Shows the current status of the client's connection to BizHawk"""
if isinstance(self.ctx, BizHawkClientContext):
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
logger.info("BizHawk Connection Status: Not Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
logger.info("BizHawk Connection Status: Tentatively Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
logger.info("BizHawk Connection Status: Connected")
assert isinstance(self.ctx, BizHawkClientContext)
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
logger.info("BizHawk Connection Status: Not Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
logger.info("BizHawk Connection Status: Tentatively Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
logger.info("BizHawk Connection Status: Connected")
def _cmd_toggle_text(self, category: str | None = None, toggle: str | None = None):
"""Sets types of incoming messages to forward to the emulator"""
assert isinstance(self.ctx, BizHawkClientContext)
if category is None:
logger.info("Usage: /toggle_text category [toggle]\n\n"
"category: incoming, outgoing, other, hint, chat, and server\n"
"Or \"all\" to toggle all categories at once\n\n"
"toggle: on, off, true, or false\n"
"Or omit to set it to the opposite of its current state\n\n"
"Example: /toggle_text outgoing on")
return
category = category.lower()
value: bool | None
if toggle is None:
value = None
elif toggle.lower() in ("on", "true"):
value = True
elif toggle.lower() in ("off", "false"):
value = False
else:
logger.info(f'Unknown value "{toggle}", should be on|off|true|false')
return
valid_categories = (
TextCategory.ALL,
TextCategory.OTHER,
TextCategory.INCOMING,
TextCategory.OUTGOING,
TextCategory.HINT,
TextCategory.CHAT,
TextCategory.SERVER,
)
if category not in valid_categories:
logger.info(f'Unknown value "{category}", should be {"|".join(valid_categories)}')
return
if category == TextCategory.ALL:
if value is None:
logger.info('Must specify "on" or "off" for category "all"')
return
if value:
self.ctx.text_passthrough_categories.update((
TextCategory.OTHER,
TextCategory.INCOMING,
TextCategory.OUTGOING,
TextCategory.HINT,
TextCategory.CHAT,
TextCategory.SERVER,
))
else:
self.ctx.text_passthrough_categories.clear()
else:
if value is None:
value = category not in self.ctx.text_passthrough_categories
if value:
self.ctx.text_passthrough_categories.add(category)
else:
self.ctx.text_passthrough_categories.remove(category)
logger.info(f"Currently Showing Categories: {', '.join(self.ctx.text_passthrough_categories)}")
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
text_passthrough_categories: set[str]
server_seed_name: str | None = None
auth_status: AuthStatus
password_requested: bool
@@ -54,12 +132,33 @@ class BizHawkClientContext(CommonContext):
def __init__(self, server_address: str | None, password: str | None):
super().__init__(server_address, password)
self.text_passthrough_categories = set()
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.password_requested = False
self.client_handler = None
self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5
def _categorize_text(self, args: dict) -> TextCategory:
if "type" not in args or args["type"] in {"Hint", "Join", "Part", "TagsChanged", "Goal", "Release", "Collect",
"Countdown", "ServerChat", "ItemCheat"}:
return TextCategory.SERVER
elif args["type"] == "Chat":
return TextCategory.CHAT
elif args["type"] == "ItemSend":
if args["item"].player == self.slot:
return TextCategory.OUTGOING
elif args["receiving"] == self.slot:
return TextCategory.INCOMING
else:
return TextCategory.OTHER
def on_print_json(self, args: dict):
super().on_print_json(args)
if self.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
if self._categorize_text(args) in self.text_passthrough_categories:
Utils.async_start(display_message(self.bizhawk_ctx, self.rawjsontotextparser(copy.deepcopy(args["data"]))))
def make_gui(self):
ui = super().make_gui()
ui.base_title = "Archipelago BizHawk Client"

View File

@@ -463,12 +463,15 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player))
set_rule(multiworld.get_location('Misery Mire - Spike Chest', player), lambda state: (world.can_take_damage and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player))
set_rule(multiworld.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player))
# How to access crystal switch:
# If have big key: then you will need 2 small keys to be able to hit switch and return to main area, as you can burn key in dark room
# If not big key: cannot burn key in dark room, hence need only 1 key. all doors immediately available lead to a crystal switch.
# The listed chests are those which can be reached if you can reach a crystal switch.
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
# The most number of keys you can burn without opening the map chest and without reaching a crystal switch is 1,
# but if you cannot activate a crystal switch except by throwing a pot, you could burn another two going through
# the conveyor crystal room.
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 2) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 4))
# Using a key on the map door chest will get you the map chest but not a crystal switch. Main Lobby should require
# one more key.
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 3) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 5))
# we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet
set_rule(multiworld.get_location('Misery Mire - Conveyor Crystal Key Drop', player),
lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4)
@@ -542,6 +545,8 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player))
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player))
set_rule(multiworld.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
set_rule(multiworld.get_location('Ganons Tower - Double Switch Pot Key', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
if world.options.pot_shuffle:
set_rule(multiworld.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
set_rule(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (

View File

@@ -32,8 +32,8 @@ class TestMiseryMire(TestDungeon):
["Misery Mire - Main Lobby", False, []],
["Misery Mire - Main Lobby", False, [], ['Pegasus Boots', 'Hookshot']],
["Misery Mire - Main Lobby", False, [], ['Small Key (Misery Mire)', 'Big Key (Misery Mire)']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
["Misery Mire - Big Key Chest", False, []],
["Misery Mire - Big Key Chest", False, [], ['Fire Rod', 'Lamp']],

View File

@@ -2,13 +2,15 @@ import binascii
import importlib.util
import importlib.machinery
import os
import pkgutil
import random
import pickle
import Utils
import settings
from collections import defaultdict
from typing import TYPE_CHECKING
from typing import Dict
from .romTables import ROMWithTables
from . import assembler
from . import mapgen
from . import patches
from .patches import overworld as _
from .patches import dungeon as _
@@ -57,27 +59,20 @@ from .patches import tradeSequence as _
from . import hints
from .patches import bank34
from .utils import formatText
from .roomEditor import RoomEditor, Object
from .patches.aesthetics import rgb_to_bin, bin_to_rgb
from .locations.keyLocation import KeyLocation
from BaseClasses import ItemClassification
from ..Locations import LinksAwakeningLocation
from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps
if TYPE_CHECKING:
from .. import LinksAwakeningWorld
from .. import Options
# Function to generate a final rom, this patches the rom with all required patches
def generateRom(args, world: "LinksAwakeningWorld"):
def generateRom(base_rom: bytes, args, patch_data: Dict):
random.seed(patch_data["seed"] + patch_data["player"])
multi_key = binascii.unhexlify(patch_data["multi_key"].encode())
item_list = pickle.loads(binascii.unhexlify(patch_data["item_list"].encode()))
options = patch_data["options"]
rom_patches = []
player_names = list(world.multiworld.player_name.values())
rom = ROMWithTables(args.input_filename, rom_patches)
rom.player_names = player_names
rom = ROMWithTables(base_rom, rom_patches)
rom.player_names = patch_data["other_player_names"]
pymods = []
if args.pymod:
for pymod in args.pymod:
@@ -88,10 +83,13 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for pymod in pymods:
pymod.prePatch(rom)
if world.ladxr_settings.gfxmod:
patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", world.ladxr_settings.gfxmod))
item_list = [item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)]
if options["gfxmod"]:
user_settings = settings.get_settings()
try:
gfx_mod_file = user_settings["ladx_options"]["gfx_mod_file"]
patches.aesthetics.gfxMod(rom, gfx_mod_file)
except FileNotFoundError:
pass # if user just doesnt provide gfxmod file, let patching continue
assembler.resetConsts()
assembler.const("INV_SIZE", 16)
@@ -121,7 +119,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
assembler.const("wLinkSpawnDelay", 0xDE13)
#assembler.const("HARDWARE_LINK", 1)
assembler.const("HARD_MODE", 1 if world.ladxr_settings.hardmode != "none" else 0)
assembler.const("HARD_MODE", 1 if options["hard_mode"] else 0)
patches.core.cleanup(rom)
patches.save.singleSaveSlot(rom)
@@ -135,7 +133,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.core.easyColorDungeonAccess(rom)
patches.owl.removeOwlEvents(rom)
patches.enemies.fixArmosKnightAsMiniboss(rom)
patches.bank3e.addBank3E(rom, world.multi_key, world.player, player_names)
patches.bank3e.addBank3E(rom, multi_key, patch_data["player"], patch_data["other_player_names"])
patches.bank3f.addBank3F(rom)
patches.bank34.addBank34(rom, item_list)
patches.core.removeGhost(rom)
@@ -144,19 +142,17 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.core.alwaysAllowSecretBook(rom)
patches.core.injectMainLoop(rom)
from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys
if world.options.shuffle_small_keys != ShuffleSmallKeys.option_original_dungeon or\
world.options.shuffle_nightmare_keys != ShuffleNightmareKeys.option_original_dungeon:
if options["shuffle_small_keys"] != Options.ShuffleSmallKeys.option_original_dungeon or\
options["shuffle_nightmare_keys"] != Options.ShuffleNightmareKeys.option_original_dungeon:
patches.inventory.advancedInventorySubscreen(rom)
patches.inventory.moreSlots(rom)
if world.ladxr_settings.witch:
patches.witch.updateWitch(rom)
# if ladxr_settings["witch"]:
patches.witch.updateWitch(rom)
patches.softlock.fixAll(rom)
if not world.ladxr_settings.rooster:
if not options["rooster"]:
patches.maptweaks.tweakMap(rom)
patches.maptweaks.tweakBirdKeyRoom(rom)
if world.ladxr_settings.overworld == "openmabe":
if options["overworld"] == Options.Overworld.option_open_mabe:
patches.maptweaks.openMabe(rom)
patches.chest.fixChests(rom)
patches.shop.fixShop(rom)
@@ -168,10 +164,10 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.tarin.updateTarin(rom)
patches.fishingMinigame.updateFinishingMinigame(rom)
patches.health.upgradeHealthContainers(rom)
if world.ladxr_settings.owlstatues in ("dungeon", "both"):
patches.owl.upgradeDungeonOwlStatues(rom)
if world.ladxr_settings.owlstatues in ("overworld", "both"):
patches.owl.upgradeOverworldOwlStatues(rom)
# if ladxr_settings["owlstatues"] in ("dungeon", "both"):
# patches.owl.upgradeDungeonOwlStatues(rom)
# if ladxr_settings["owlstatues"] in ("overworld", "both"):
# patches.owl.upgradeOverworldOwlStatues(rom)
patches.goldenLeaf.fixGoldenLeaf(rom)
patches.heartPiece.fixHeartPiece(rom)
patches.seashell.fixSeashell(rom)
@@ -180,143 +176,95 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.songs.upgradeMarin(rom)
patches.songs.upgradeManbo(rom)
patches.songs.upgradeMamu(rom)
patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings)
patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal')
if world.ladxr_settings.bowwow != 'normal':
patches.bowwow.bowwowMapPatches(rom)
patches.tradeSequence.patchTradeSequence(rom, options)
patches.bowwow.fixBowwow(rom, everywhere=False)
# if ladxr_settings["bowwow"] != 'normal':
# patches.bowwow.bowwowMapPatches(rom)
patches.desert.desertAccess(rom)
if world.ladxr_settings.overworld == 'dungeondive':
patches.overworld.patchOverworldTilesets(rom)
patches.overworld.createDungeonOnlyOverworld(rom)
elif world.ladxr_settings.overworld == 'nodungeons':
patches.dungeon.patchNoDungeons(rom)
elif world.ladxr_settings.overworld == 'random':
patches.overworld.patchOverworldTilesets(rom)
mapgen.store_map(rom, world.ladxr_logic.world.map)
# if ladxr_settings["overworld"] == 'dungeondive':
# patches.overworld.patchOverworldTilesets(rom)
# patches.overworld.createDungeonOnlyOverworld(rom)
# elif ladxr_settings["overworld"] == 'nodungeons':
# patches.dungeon.patchNoDungeons(rom)
#elif world.ladxr_settings["overworld"] == 'random':
# patches.overworld.patchOverworldTilesets(rom)
# mapgen.store_map(rom, world.ladxr_logic.world.map)
#if settings.dungeon_items == 'keysy':
# patches.dungeon.removeKeyDoors(rom)
# patches.reduceRNG.slowdownThreeOfAKind(rom)
patches.reduceRNG.fixHorseHeads(rom)
patches.bomb.onlyDropBombsWhenHaveBombs(rom)
if world.options.music_change_condition == MusicChangeCondition.option_always:
if options["music_change_condition"] == Options.MusicChangeCondition.option_always:
patches.aesthetics.noSwordMusic(rom)
patches.aesthetics.reduceMessageLengths(rom, world.random)
patches.aesthetics.reduceMessageLengths(rom, random)
patches.aesthetics.allowColorDungeonSpritesEverywhere(rom)
if world.ladxr_settings.music == 'random':
patches.music.randomizeMusic(rom, world.random)
elif world.ladxr_settings.music == 'off':
if options["music"] == Options.Music.option_shuffled:
patches.music.randomizeMusic(rom, random)
elif options["music"] == Options.Music.option_off:
patches.music.noMusic(rom)
if world.ladxr_settings.noflash:
if options["no_flash"]:
patches.aesthetics.removeFlashingLights(rom)
if world.ladxr_settings.hardmode == "oracle":
if options["hard_mode"] == Options.HardMode.option_oracle:
patches.hardMode.oracleMode(rom)
elif world.ladxr_settings.hardmode == "hero":
elif options["hard_mode"] == Options.HardMode.option_hero:
patches.hardMode.heroMode(rom)
elif world.ladxr_settings.hardmode == "ohko":
elif options["hard_mode"] == Options.HardMode.option_ohko:
patches.hardMode.oneHitKO(rom)
if world.ladxr_settings.superweapons:
patches.weapons.patchSuperWeapons(rom)
if world.ladxr_settings.textmode == 'fast':
#if ladxr_settings["superweapons"]:
# patches.weapons.patchSuperWeapons(rom)
if options["text_mode"] == Options.TextMode.option_fast:
patches.aesthetics.fastText(rom)
if world.ladxr_settings.textmode == 'none':
patches.aesthetics.fastText(rom)
patches.aesthetics.noText(rom)
if not world.ladxr_settings.nagmessages:
#if ladxr_settings["textmode"] == 'none':
# patches.aesthetics.fastText(rom)
# patches.aesthetics.noText(rom)
if not options["nag_messages"]:
patches.aesthetics.removeNagMessages(rom)
if world.ladxr_settings.lowhpbeep == 'slow':
if options["low_hp_beep"] == Options.LowHpBeep.option_slow:
patches.aesthetics.slowLowHPBeep(rom)
if world.ladxr_settings.lowhpbeep == 'none':
if options["low_hp_beep"] == Options.LowHpBeep.option_none:
patches.aesthetics.removeLowHPBeep(rom)
if 0 <= int(world.ladxr_settings.linkspalette):
patches.aesthetics.forceLinksPalette(rom, int(world.ladxr_settings.linkspalette))
if 0 <= options["link_palette"]:
patches.aesthetics.forceLinksPalette(rom, options["link_palette"])
if args.romdebugmode:
# The default rom has this build in, just need to set a flag and we get this save.
rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around.
if world.ladxr_settings.steal == 'never':
rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
elif world.ladxr_settings.steal == 'always':
rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
#if ladxr_settings["steal"] == 'never':
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
#elif ladxr_settings["steal"] == 'always':
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
if world.ladxr_settings.hpmode == 'inverted':
patches.health.setStartHealth(rom, 9)
elif world.ladxr_settings.hpmode == '1':
patches.health.setStartHealth(rom, 1)
#if ladxr_settings["hpmode"] == 'inverted':
# patches.health.setStartHealth(rom, 9)
#elif ladxr_settings["hpmode"] == '1':
# patches.health.setStartHealth(rom, 1)
patches.inventory.songSelectAfterOcarinaSelect(rom)
if world.ladxr_settings.quickswap == 'a':
if options["quickswap"] == 'a':
patches.core.quickswap(rom, 1)
elif world.ladxr_settings.quickswap == 'b':
elif options["quickswap"] == 'b':
patches.core.quickswap(rom, 0)
patches.core.addBootsControls(rom, world.options.boots_controls)
patches.core.addBootsControls(rom, options["boots_controls"])
random.seed(patch_data["seed"] + patch_data["player"])
hints.addHints(rom, random, patch_data["hint_texts"])
world_setup = world.ladxr_logic.world_setup
JUNK_HINT = 0.33
RANDOM_HINT= 0.66
# USEFUL_HINT = 1.0
# TODO: filter events, filter unshuffled keys
all_items = world.multiworld.get_items()
our_items = [item for item in all_items
if item.player == world.player
and item.location
and item.code is not None
and item.location.show_in_spoiler]
our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification]
def gen_hint():
if not world.options.in_game_hints:
return 'Hints are disabled!'
chance = world.random.uniform(0, 1)
if chance < JUNK_HINT:
return None
elif chance < RANDOM_HINT:
location = world.random.choice(our_items).location
else: # USEFUL_HINT
location = world.random.choice(our_useful_items).location
if location.item.player == world.player:
name = "Your"
else:
name = f"{world.multiworld.player_name[location.item.player]}'s"
# filter out { and } since they cause issues with string.format later on
name = name.replace("{", "").replace("}", "")
if isinstance(location, LinksAwakeningLocation):
location_name = location.ladxr_item.metadata.name
else:
location_name = location.name
hint = f"{name} {location.item.name} is at {location_name}"
if location.player != world.player:
# filter out { and } since they cause issues with string.format later on
player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "")
hint += f" in {player_name}'s world"
# Cap hint size at 85
# Realistically we could go bigger but let's be safe instead
hint = hint[:85]
return hint
hints.addHints(rom, world.random, gen_hint)
if world_setup.goal == "raft":
if patch_data["world_setup"]["goal"] == "raft":
patches.goal.setRaftGoal(rom)
elif world_setup.goal in ("bingo", "bingo-full"):
patches.bingo.setBingoGoal(rom, world_setup.bingo_goals, world_setup.goal)
elif world_setup.goal == "seashells":
elif patch_data["world_setup"]["goal"] in ("bingo", "bingo-full"):
patches.bingo.setBingoGoal(rom, patch_data["world_setup"]["bingo_goals"], patch_data["world_setup"]["goal"])
elif patch_data["world_setup"]["goal"] == "seashells":
patches.goal.setSeashellGoal(rom, 20)
else:
patches.goal.setRequiredInstrumentCount(rom, world_setup.goal)
patches.goal.setRequiredInstrumentCount(rom, patch_data["world_setup"]["goal"])
# Patch the generated logic into the rom
patches.chest.setMultiChest(rom, world_setup.multichest)
if world.ladxr_settings.overworld not in {"dungeondive", "random"}:
patches.entrances.changeEntrances(rom, world_setup.entrance_mapping)
patches.chest.setMultiChest(rom, patch_data["world_setup"]["multichest"])
#if ladxr_settings["overworld"] not in {"dungeondive", "random"}:
patches.entrances.changeEntrances(rom, patch_data["world_setup"]["entrance_mapping"])
for spot in item_list:
if spot.item and spot.item.startswith("*"):
spot.item = spot.item[1:]
@@ -327,23 +275,22 @@ def generateRom(args, world: "LinksAwakeningWorld"):
# There are only 101 player name slots (99 + "The Server" + "another world"), so don't use more than that
mw = 100
spot.patch(rom, spot.item, multiworld=mw)
patches.enemies.changeBosses(rom, world_setup.boss_mapping)
patches.enemies.changeMiniBosses(rom, world_setup.miniboss_mapping)
patches.enemies.changeBosses(rom, patch_data["world_setup"]["boss_mapping"])
patches.enemies.changeMiniBosses(rom, patch_data["world_setup"]["miniboss_mapping"])
if not args.romdebugmode:
patches.core.addFrameCounter(rom, len(item_list))
patches.core.warpHome(rom) # Needs to be done after setting the start location.
patches.titleScreen.setRomInfo(rom, world.multi_key, world.multiworld.seed_name, world.ladxr_settings,
world.player_name, world.player)
if world.options.ap_title_screen:
patches.titleScreen.setRomInfo(rom, patch_data)
if options["ap_title_screen"]:
patches.titleScreen.setTitleGraphics(rom)
patches.endscreen.updateEndScreen(rom)
patches.aesthetics.updateSpriteData(rom)
if args.doubletrouble:
patches.enemies.doubleTrouble(rom)
if world.options.text_shuffle:
if options["text_shuffle"]:
excluded_ids = [
# Overworld owl statues
0x1B6, 0x1B7, 0x1B8, 0x1B9, 0x1BA, 0x1BB, 0x1BC, 0x1BD, 0x1BE, 0x22D,
@@ -388,6 +335,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
excluded_texts = [ rom.texts[excluded_id] for excluded_id in excluded_ids]
buckets = defaultdict(list)
# For each ROM bank, shuffle text within the bank
random.seed(patch_data["seed"] + patch_data["player"])
for n, data in enumerate(rom.texts._PointerTable__data):
# Don't muck up which text boxes are questions and which are statements
if type(data) != int and data and data != b'\xFF' and data not in excluded_texts:
@@ -395,20 +343,20 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for bucket in buckets.values():
# For each bucket, make a copy and shuffle
shuffled = bucket.copy()
world.random.shuffle(shuffled)
random.shuffle(shuffled)
# Then put new text in
for bucket_idx, (orig_idx, data) in enumerate(bucket):
rom.texts[shuffled[bucket_idx][0]] = data
if world.options.trendy_game != TrendyGame.option_normal:
if options["trendy_game"] != Options.TrendyGame.option_normal:
# TODO: if 0 or 4, 5, remove inaccurate conveyor tiles
room_editor = RoomEditor(rom, 0x2A0)
if world.options.trendy_game == TrendyGame.option_easy:
if options["trendy_game"] == Options.TrendyGame.option_easy:
# Set physics flag on all objects
for i in range(0, 6):
rom.banks[0x4][0x6F1E + i -0x4000] = 0x4
@@ -419,7 +367,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
# Add new conveyor to "push" yoshi (it's only a visual)
room_editor.objects.append(Object(5, 3, 0xD0))
if world.options.trendy_game >= TrendyGame.option_harder:
if options["trendy_game"] >= Options.TrendyGame.option_harder:
"""
Data_004_76A0::
db $FC, $00, $04, $00, $00
@@ -428,17 +376,18 @@ def generateRom(args, world: "LinksAwakeningWorld"):
db $00, $04, $00, $FC, $00
"""
speeds = {
TrendyGame.option_harder: (3, 8),
TrendyGame.option_hardest: (3, 8),
TrendyGame.option_impossible: (3, 16),
Options.TrendyGame.option_harder: (3, 8),
Options.TrendyGame.option_hardest: (3, 8),
Options.TrendyGame.option_impossible: (3, 16),
}
def speed():
return world.random.randint(*speeds[world.options.trendy_game])
random.seed(patch_data["seed"] + patch_data["player"])
return random.randint(*speeds[options["trendy_game"]])
rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A2-0x4000] = speed()
rom.banks[0x4][0x76A6-0x4000] = speed()
rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed()
if world.options.trendy_game >= TrendyGame.option_hardest:
if options["trendy_game"] >= Options.TrendyGame.option_hardest:
rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A3-0x4000] = speed()
rom.banks[0x4][0x76A5-0x4000] = speed()
@@ -462,11 +411,11 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for channel in range(3):
color[channel] = color[channel] * 31 // 0xbc
if world.options.warps != Warps.option_vanilla:
patches.core.addWarpImprovements(rom, world.options.warps == Warps.option_improved_additional)
if options["warps"] != Options.Warps.option_vanilla:
patches.core.addWarpImprovements(rom, options["warps"] == Options.Warps.option_improved_additional)
palette = world.options.palette
if palette != Palette.option_normal:
palette = options["palette"]
if palette != Options.Palette.option_normal:
ranges = {
# Object palettes
# Overworld palettes
@@ -496,22 +445,22 @@ def generateRom(args, world: "LinksAwakeningWorld"):
r,g,b = bin_to_rgb(packed)
# 1 bit
if palette == Palette.option_1bit:
if palette == Options.Palette.option_1bit:
r &= 0b10000
g &= 0b10000
b &= 0b10000
# 2 bit
elif palette == Palette.option_1bit:
elif palette == Options.Palette.option_1bit:
r &= 0b11000
g &= 0b11000
b &= 0b11000
# Invert
elif palette == Palette.option_inverted:
elif palette == Options.Palette.option_inverted:
r = 31 - r
g = 31 - g
b = 31 - b
# Pink
elif palette == Palette.option_pink:
elif palette == Options.Palette.option_pink:
r = r // 2
r += 16
r = int(r)
@@ -520,7 +469,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
b += 16
b = int(b)
b = clamp(b, 0, 0x1F)
elif palette == Palette.option_greyscale:
elif palette == Options.Palette.option_greyscale:
# gray=int(0.299*r+0.587*g+0.114*b)
gray = (r + g + b) // 3
r = g = b = gray
@@ -531,10 +480,10 @@ def generateRom(args, world: "LinksAwakeningWorld"):
SEED_LOCATION = 0x0134
# Patch over the title
assert(len(world.multi_key) == 12)
rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(world.multi_key))
assert(len(multi_key) == 12)
rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(multi_key))
for pymod in pymods:
pymod.postPatch(rom)
return rom
return rom.save()

View File

@@ -1,5 +1,7 @@
from .locations.items import *
from .utils import formatText
from BaseClasses import ItemClassification
from ..Locations import LinksAwakeningLocation
hint_text_ids = [
@@ -49,14 +51,64 @@ useless_hint = [
]
def addHints(rom, rnd, hint_generator):
def addHints(rom, rnd, hint_texts):
hint_texts_copy = hint_texts.copy()
text_ids = hint_text_ids.copy()
rnd.shuffle(text_ids)
for text_id in text_ids:
hint = hint_generator()
hint = hint_texts_copy.pop()
if not hint:
hint = rnd.choice(hints).format(*rnd.choice(useless_hint))
rom.texts[text_id] = formatText(hint)
for text_id in range(0x200, 0x20C, 2):
rom.texts[text_id] = formatText("Read this book?", ask="YES NO")
def generate_hint_texts(world):
JUNK_HINT = 0.33
RANDOM_HINT= 0.66
# USEFUL_HINT = 1.0
# TODO: filter events, filter unshuffled keys
all_items = world.multiworld.get_items()
our_items = [item for item in all_items
if item.player == world.player
and item.location
and item.code is not None
and item.location.show_in_spoiler]
our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification]
hint_texts = []
def gen_hint():
chance = world.random.uniform(0, 1)
if chance < JUNK_HINT:
return None
elif chance < RANDOM_HINT:
location = world.random.choice(our_items).location
else: # USEFUL_HINT
location = world.random.choice(our_useful_items).location
if location.item.player == world.player:
name = "Your"
else:
name = f"{world.multiworld.player_name[location.item.player]}'s"
# filter out { and } since they cause issues with string.format later on
name = name.replace("{", "").replace("}", "")
if isinstance(location, LinksAwakeningLocation):
location_name = location.ladxr_item.metadata.name
else:
location_name = location.name
hint = f"{name} {location.item} is at {location_name}"
if location.player != world.player:
# filter out { and } since they cause issues with string.format later on
player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "")
hint += f" in {player_name}'s world"
# Cap hint size at 85
# Realistically we could go bigger but let's be safe instead
hint = hint[:85]
return hint
for _ in hint_text_ids:
hint_texts.append(gen_hint())
return hint_texts

View File

@@ -180,9 +180,10 @@ def noText(rom):
def reduceMessageLengths(rom, rnd):
# Into text from Marin. Got to go fast, so less text. (This intro text is very long)
lines = pkgutil.get_data(__name__, "marin.txt").decode("unicode_escape").splitlines()
lines = [l for l in lines if l.strip()]
rom.texts[0x01] = formatText(rnd.choice(lines).strip())
lines = pkgutil.get_data(__name__, "marin.txt").splitlines(keepends=True)
while lines and lines[-1].strip() == b'':
lines.pop(-1)
rom.texts[0x01] = formatText(rnd.choice(lines).strip().decode("unicode_escape"))
# Reduce length of a bunch of common texts
rom.texts[0xEA] = formatText("You've got a Guardian Acorn!")

View File

@@ -541,7 +541,7 @@ OAMData:
rom.banks[0x38][0x1400+n*0x20:0x1410+n*0x20] = utils.createTileData(gfx_high)
rom.banks[0x38][0x1410+n*0x20:0x1420+n*0x20] = utils.createTileData(gfx_low)
def addBootsControls(rom, boots_controls: BootsControls):
def addBootsControls(rom, boots_controls: int):
if boots_controls == BootsControls.option_vanilla:
return
consts = {
@@ -578,7 +578,7 @@ def addBootsControls(rom, boots_controls: BootsControls):
jr z, .yesBoots
ld a, [hl]
"""
}[boots_controls.value]
}[boots_controls]
# The new code fits exactly within Nintendo's poorly space optimzied code while having more features
boots_code = assembler.ASM("""

View File

@@ -42,7 +42,7 @@ MINIBOSS_ENTITIES = {
"ARMOS_KNIGHT": [(4, 3, 0x88)],
}
MINIBOSS_ROOMS = {
0: 0x111, 1: 0x128, 2: 0x145, 3: 0x164, 4: 0x193, 5: 0x1C5, 6: 0x228, 7: 0x23F,
"0": 0x111, "1": 0x128, "2": 0x145, "3": 0x164, "4": 0x193, "5": 0x1C5, "6": 0x228, "7": 0x23F,
"c1": 0x30C, "c2": 0x303,
"moblin_cave": 0x2E1,
"armos_temple": 0x27F,

View File

@@ -1,7 +1,6 @@
from ..backgroundEditor import BackgroundEditor
from .aesthetics import rgb_to_bin, bin_to_rgb, prepatch
import copy
import pkgutil
CHAR_MAP = {'z': 0x3E, '-': 0x3F, '.': 0x39, ':': 0x42, '?': 0x3C, '!': 0x3D}
def _encode(s):
@@ -18,17 +17,18 @@ def _encode(s):
return result
def setRomInfo(rom, seed, seed_name, settings, player_name, player_id):
def setRomInfo(rom, patch_data):
seed_name = patch_data["seed_name"]
try:
seednr = int(seed, 16)
seednr = int(patch_data["seed"], 16)
except:
import hashlib
seednr = int(hashlib.md5(seed).hexdigest(), 16)
seednr = int(hashlib.md5(str(patch_data["seed"]).encode()).hexdigest(), 16)
if settings.race:
if patch_data["is_race"]:
seed_name = "Race"
if isinstance(settings.race, str):
seed_name += " " + settings.race
if isinstance(patch_data["is_race"], str):
seed_name += " " + patch_data["is_race"]
rom.patch(0x00, 0x07, "00", "01")
else:
rom.patch(0x00, 0x07, "00", "52")
@@ -37,7 +37,7 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id):
#line_2_hex = _encode(seed[16:])
BASE_DRAWING_AREA = 0x98a0
LINE_WIDTH = 0x20
player_id_text = f"Player {player_id}:"
player_id_text = f"Player {patch_data['player']}:"
for n in (3, 4):
be = BackgroundEditor(rom, n)
ba = BackgroundEditor(rom, n, attributes=True)
@@ -45,9 +45,9 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id):
for n, v in enumerate(_encode(player_id_text)):
be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = v
ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = 0x00
for n, v in enumerate(_encode(player_name)):
be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = v
ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = 0x00
for n, v in enumerate(_encode(patch_data['player_name'])):
be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = v
ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = 0x00
for n, v in enumerate(line_1_hex):
be.tiles[0x9a20 + n] = v
ba.tiles[0x9a20 + n] = 0x00

View File

@@ -387,7 +387,7 @@ def patchVarious(rom, settings):
# Boomerang trade guy
# if settings.boomerang not in {'trade', 'gift'} or settings.overworld in {'normal', 'nodungeons'}:
if settings.tradequest:
if settings["tradequest"]:
# Update magnifier checks
rom.patch(0x19, 0x05EC, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njp nz, $7E61"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njp z, $7E61")) # show the guy
rom.patch(0x00, 0x3199, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njr nz, $06"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njr z, $06")) # load the proper room layout

View File

@@ -7,9 +7,7 @@ h2b = binascii.unhexlify
class ROM:
def __init__(self, filename, patches=None):
data = open(Utils.user_path(filename), "rb").read()
def __init__(self, data, patches=None):
if patches:
for patch in patches:
data = bsdiff4.patch(data, patch)
@@ -64,18 +62,10 @@ class ROM:
self.banks[0][0x14E] = checksum >> 8
self.banks[0][0x14F] = checksum & 0xFF
def save(self, file, *, name=None):
def save(self):
# don't pass the name to fixHeader
self.fixHeader()
if isinstance(file, str):
f = open(file, "wb")
for bank in self.banks:
f.write(bank)
f.close()
print("Saved:", file)
else:
for bank in self.banks:
file.write(bank)
return b"".join(self.banks)
def readHexSeed(self):
return self.banks[0x3E][0x2F00:0x2F10].hex().upper()

View File

@@ -181,8 +181,8 @@ class IndoorRoomSpriteData(PointerTable):
class ROMWithTables(ROM):
def __init__(self, filename, patches=None):
super().__init__(filename, patches)
def __init__(self, data, patches=None):
super().__init__(data, patches)
# Ability to patch any text in the game with different text
self.texts = Texts(self)
@@ -203,7 +203,7 @@ class ROMWithTables(ROM):
self.itemNames = {}
def save(self, filename, *, name=None):
def save(self):
# Assert special handling of bank 9 expansion is fine
for i in range(0x3d42, 0x4000):
assert self.banks[9][i] == 0, self.banks[9][i]
@@ -221,4 +221,4 @@ class ROMWithTables(ROM):
self.room_sprite_data_indoor.store(self)
self.background_tiles.store(self)
self.background_attributes.store(self)
super().save(filename, name=name)
return super().save()

View File

@@ -425,46 +425,11 @@ class TrendyGame(Choice):
default = option_normal
class GfxMod(FreeText, LADXROption):
class GfxMod(DefaultOffToggle):
"""
Sets the sprite for link, among other things
The option should be the same name as a with sprite (and optional name) file in data/sprites/ladx
If enabled, the patcher will prompt the user for a modification file to change sprites in the game and optionally some text.
"""
display_name = "GFX Modification"
ladxr_name = "gfxmod"
normal = ''
default = 'Link'
__spriteDir: str = Utils.local_path(os.path.join('data', 'sprites', 'ladx'))
__spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list)
extensions = [".bin", ".bdiff", ".png", ".bmp"]
for file in os.listdir(__spriteDir):
name, extension = os.path.splitext(file)
if extension in extensions:
__spriteFiles[name].append(file)
def __init__(self, value: str):
super().__init__(value)
def verify(self, world, player_name: str, plando_options) -> None:
if self.value == "Link" or self.value in GfxMod.__spriteFiles:
return
raise Exception(
f"LADX Sprite '{self.value}' not found. Possible sprites are: {['Link'] + list(GfxMod.__spriteFiles.keys())}")
def to_ladxr_option(self, all_options):
if self.value == -1 or self.value == "Link":
return None, None
assert self.value in GfxMod.__spriteFiles
if len(GfxMod.__spriteFiles[self.value]) > 1:
logger.warning(
f"{self.value} does not uniquely identify a file. Possible matches: {GfxMod.__spriteFiles[self.value]}. Using {GfxMod.__spriteFiles[self.value][0]}")
return self.ladxr_name, self.__spriteDir + "/" + GfxMod.__spriteFiles[self.value][0]
class Palette(Choice):

View File

@@ -3,19 +3,112 @@ import worlds.Files
import hashlib
import Utils
import os
import json
import pkgutil
import bsdiff4
import binascii
import pickle
from typing import TYPE_CHECKING
from .Common import *
from .LADXR import generator
from .LADXR.main import get_parser
from .LADXR.hints import generate_hint_texts
from .LADXR.locations.keyLocation import KeyLocation
LADX_HASH = "07c211479386825042efb4ad31bb525f"
class LADXDeltaPatch(worlds.Files.APDeltaPatch):
if TYPE_CHECKING:
from . import LinksAwakeningWorld
class LADXPatchExtensions(worlds.Files.APPatchExtension):
game = LINKS_AWAKENING
@staticmethod
def generate_rom(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes:
patch_data = json.loads(caller.get_file(data_file).decode("utf-8"))
# TODO local option overrides
rom_name = get_base_rom_path()
out_name = f"{patch_data['out_base']}{caller.result_file_ending}"
parser = get_parser()
args = parser.parse_args([rom_name, "-o", out_name, "--dump"])
return generator.generateRom(rom, args, patch_data)
@staticmethod
def patch_title_screen(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes:
patch_data = json.loads(caller.get_file(data_file).decode("utf-8"))
if patch_data["options"]["ap_title_screen"]:
return bsdiff4.patch(rom, pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4"))
return rom
class LADXProcedurePatch(worlds.Files.APProcedurePatch):
hash = LADX_HASH
game = "Links Awakening DX"
patch_file_ending = ".apladx"
game = LINKS_AWAKENING
patch_file_ending: str = ".apladx"
result_file_ending: str = ".gbc"
procedure = [
("generate_rom", ["data.json"]),
("patch_title_screen", ["data.json"])
]
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes()
def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
item_list = pickle.dumps([item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)])
data_dict = {
"out_base": world.multiworld.get_out_file_name_base(patch.player),
"is_race": world.multiworld.is_race,
"seed": world.multiworld.seed,
"seed_name": world.multiworld.seed_name,
"multi_key": binascii.hexlify(world.multi_key).decode(),
"player": patch.player,
"player_name": patch.player_name,
"other_player_names": list(world.multiworld.player_name.values()),
"item_list": binascii.hexlify(item_list).decode(),
"hint_texts": generate_hint_texts(world),
"world_setup": {
"goal": world.ladxr_logic.world_setup.goal,
"bingo_goals": world.ladxr_logic.world_setup.bingo_goals,
"multichest": world.ladxr_logic.world_setup.multichest,
"entrance_mapping": world.ladxr_logic.world_setup.entrance_mapping,
"boss_mapping": world.ladxr_logic.world_setup.boss_mapping,
"miniboss_mapping": world.ladxr_logic.world_setup.miniboss_mapping,
},
"options": world.options.as_dict(
"tradequest",
"rooster",
"experimental_dungeon_shuffle",
"experimental_entrance_shuffle",
"goal",
"instrument_count",
"link_palette",
"warps",
"trendy_game",
"gfxmod",
"palette",
"text_shuffle",
"shuffle_nightmare_keys",
"shuffle_small_keys",
"music",
"music_change_condition",
"nag_messages",
"ap_title_screen",
"boots_controls",
# "stealing",
"quickswap",
"hard_mode",
"low_hp_beep",
"text_mode",
"no_flash",
"overworld",
),
}
patch.write_file("data.json", json.dumps(data_dict).encode('utf-8'))
def get_base_rom_bytes(file_name: str = "") -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:

View File

@@ -1,16 +1,12 @@
import binascii
import dataclasses
import os
import pkgutil
import tempfile
import typing
import logging
import re
import bsdiff4
import settings
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
from .Common import *
@@ -18,19 +14,17 @@ from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name,
links_awakening_item_name_groups)
from .LADXR import generator
from .LADXR.itempool import ItemPool as LADXRItemPool
from .LADXR.locations.constants import CHEST_ITEMS
from .LADXR.locations.instrument import Instrument
from .LADXR.logic import Logic as LADXRLogic
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,
create_regions_from_ladxr, get_locations_to_id,
links_awakening_location_name_groups)
from .Options import DungeonItemShuffle, ShuffleInstruments, LinksAwakeningOptions, ladx_option_groups
from .Rom import LADXDeltaPatch, get_base_rom_path
from .Rom import LADXProcedurePatch, write_patch_data
DEVELOPER_MODE = False
@@ -40,7 +34,7 @@ class LinksAwakeningSettings(settings.Group):
"""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]
md5s = [LADXProcedurePatch.hash]
class RomStart(str):
"""
@@ -57,8 +51,16 @@ class LinksAwakeningSettings(settings.Group):
class DisplayMsgs(settings.Bool):
"""Display message inside of Bizhawk"""
class GfxModFile(settings.FilePath):
"""
Gfxmod file, get it from upstream: https://github.com/daid/LADXR/tree/master/gfx
Only .bin or .bdiff files
The same directory will be checked for a matching text modification file
"""
rom_file: RomFile = RomFile(RomFile.copy_to)
rom_start: typing.Union[RomStart, bool] = True
gfx_mod_file: GfxModFile = GfxModFile()
class LinksAwakeningWebWorld(WebWorld):
tutorials = [Tutorial(
@@ -179,10 +181,10 @@ class LinksAwakeningWorld(World):
assert(start)
menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld)
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
@@ -190,14 +192,14 @@ class LinksAwakeningWorld(World):
for loc in region.locations:
if loc.address is None:
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):
@@ -279,8 +281,8 @@ class LinksAwakeningWorld(World):
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"))
self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []]
for r in self.multiworld.get_regions(self.player):
# Set aside dungeon locations
if r.dungeon_index:
@@ -354,7 +356,7 @@ class LinksAwakeningWorld(World):
# set containing the list of all possible dungeon locations for the player
all_dungeon_locs = set()
# Do dungeon specific things
for dungeon_index in range(0, 9):
# set up allow-list for dungeon specific items
@@ -367,7 +369,7 @@ class LinksAwakeningWorld(World):
# ...also set the rules for the dungeon
for location in locs:
orig_rule = location.item_rule
# If an item is about to be placed on a dungeon location, it can go there iff
# If an item is about to be placed on a dungeon location, it can go there iff
# 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
@@ -421,7 +423,7 @@ class LinksAwakeningWorld(World):
partial_all_state.sweep_for_advancements()
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)
name_cache = {}
# Tries to associate an icon from another game with an icon we have
@@ -458,22 +460,16 @@ class LinksAwakeningWorld(World):
for name in possibles:
if name in self.name_cache:
return self.name_cache[name]
return "TRADING_ITEM_LETTER"
@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)
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
@@ -499,31 +495,13 @@ class LinksAwakeningWorld(World):
# Kind of kludge, make it possible for the location to differentiate between local and remote items
loc.ladxr_item.location_owner = self.player
rom_name = Rom.get_base_rom_path()
out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.player_name}.gbc"
out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc")
patch = LADXProcedurePatch(player=self.player, player_name=self.player_name)
write_patch_data(self, patch)
out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}"
f"{patch.patch_file_ending}")
parser = get_parser()
args = parser.parse_args([rom_name, "-o", out_name, "--dump"])
rom = generator.generateRom(args, self)
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.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)
patch = LADXDeltaPatch(os.path.splitext(out_path)[0]+LADXDeltaPatch.patch_file_ending, player=self.player,
player_name=self.player_name, patched_path=out_path)
patch.write()
if not DEVELOPER_MODE:
os.unlink(out_path)
patch.write(out_path)
def generate_multi_key(self):
return bytearray(self.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big')

View File

@@ -654,4 +654,11 @@ SONG_DATA: Dict[str, SongData] = {
"#YamiKawa": SongData(2900778, "43-65", "MD Plus Project", False, 5, 7, 10),
"Rainy Step": SongData(2900779, "43-66", "MD Plus Project", False, 2, 5, 8),
"OHOSHIKATSU": SongData(2900780, "43-67", "MD Plus Project", False, 5, 7, 10),
"Dreamy Day": SongData(2900781, "87-0", "Aim to Be a Rhythm Master!", False, 2, 5, 7),
"Futropolis": SongData(2900782, "87-1", "Aim to Be a Rhythm Master!", False, 4, 7, 9),
"Quo Vadis": SongData(2900783, "87-2", "Aim to Be a Rhythm Master!", False, 5, 7, 10),
"REANIMATE": SongData(2900784, "87-3", "Aim to Be a Rhythm Master!", False, 5, 7, 10),
"Ineffabilis": SongData(2900785, "87-4", "Aim to Be a Rhythm Master!", False, 3, 7, 10),
"DaJiaHao": SongData(2900786, "87-5", "Aim to Be a Rhythm Master!", False, 5, 7, 10),
"Echoes of SeraphiM": SongData(2900787, "87-6", "Aim to Be a Rhythm Master!", False, 5, 8, 10),
}

View File

@@ -175,6 +175,13 @@ class ExcludeSongs(SongSet):
"""
display_name = "Exclude Songs"
class GoalSong(SongSet):
"""
One of the selected songs will be guaranteed to show up as the final Goal Song.
- You must have the DLC enabled to play these songs.
- If no songs are chosen, then the song will be randomly chosen from the available songs.
"""
display_name = "Goal Song"
md_option_groups = [
OptionGroup("Song Choice", [
@@ -182,6 +189,7 @@ md_option_groups = [
StreamerModeEnabled,
IncludeSongs,
ExcludeSongs,
GoalSong,
]),
OptionGroup("Difficulty", [
GradeNeeded,
@@ -214,6 +222,7 @@ class MuseDashOptions(PerGameCommonOptions):
death_link: DeathLink
include_songs: IncludeSongs
exclude_songs: ExcludeSongs
goal_song: GoalSong
# Removed
allow_just_as_planned_dlc_songs: Removed

View File

@@ -119,12 +119,24 @@ class MuseDashWorld(World):
start_items = self.options.start_inventory.value.keys()
include_songs = self.options.include_songs.value
exclude_songs = self.options.exclude_songs.value
chosen_goal_songs = sorted(self.options.goal_song)
self.starting_songs = [s for s in start_items if s in song_items]
self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs)
self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs]
self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs)
# Making sure songs chosen for goal are allowed by DLC and remove the chosen from being added to the pool.
if chosen_goal_songs:
chosen_goal_songs = self.md_collection.filter_songs_to_dlc(chosen_goal_songs, dlc_songs)
if chosen_goal_songs:
self.random.shuffle(chosen_goal_songs)
self.victory_song_name = chosen_goal_songs.pop()
if self.victory_song_name in self.starting_songs:
self.starting_songs.remove(self.victory_song_name)
if self.victory_song_name in self.included_songs:
self.included_songs.remove(self.victory_song_name)
return [s for s in available_song_keys if s not in start_items
and s not in include_songs and s not in exclude_songs]
@@ -139,12 +151,13 @@ class MuseDashWorld(World):
if included_song_count > additional_song_count:
# If so, we want to thin the list, thus let's get the goal song and starter songs while we are at it.
self.random.shuffle(self.included_songs)
self.victory_song_name = self.included_songs.pop()
if not self.victory_song_name:
self.victory_song_name = self.included_songs.pop()
while len(self.included_songs) > additional_song_count:
next_song = self.included_songs.pop()
if len(self.starting_songs) < starting_song_count:
self.starting_songs.append(next_song)
else:
elif not self.victory_song_name:
# If not, choose a random victory song from the available songs
chosen_song = self.random.randrange(0, len(available_song_keys) + included_song_count)
if chosen_song < included_song_count:
@@ -153,6 +166,8 @@ class MuseDashWorld(World):
else:
self.victory_song_name = available_song_keys[chosen_song - included_song_count]
del available_song_keys[chosen_song - included_song_count]
elif self.victory_song_name in available_song_keys:
available_song_keys.remove(self.victory_song_name)
# Next, make sure the starting songs are fulfilled
if len(self.starting_songs) < starting_song_count:
@@ -173,7 +188,7 @@ class MuseDashWorld(World):
def create_item(self, name: str) -> Item:
if name == self.md_collection.MUSIC_SHEET_NAME:
return MuseDashFixedItem(name, ItemClassification.progression_skip_balancing,
return MuseDashFixedItem(name, ItemClassification.progression_deprioritized_skip_balancing,
self.md_collection.MUSIC_SHEET_CODE, self.player)
filler = self.md_collection.filler_items.get(name)

View File

@@ -91,6 +91,14 @@ class TWWWeb(WebWorld):
"setup_en.md",
"setup/en",
["tanjo3", "Lunix"],
),
Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Archipelago The Wind Waker software on your computer.",
"Français",
"setup_fr.md",
"setup/fr",
["mobby45"]
)
]
theme = "ocean"

View File

@@ -116,6 +116,7 @@ This randomizer would not be possible without the help from:
- Gamma / SageOfMirrors: (additional programming)
- LagoLunatic: (base randomizer, additional assistance)
- Lunix: (Linux support, additional programming)
- mobby45: (French Translation of Guides)
- Mysteryem: (tracker support, additional programming)
- Necrofitz: (additional documentation)
- Ouro: (tracker support)

View File

@@ -0,0 +1,142 @@
# The Wind Waker
## Où est la page d'options ?
La [page d'option pour ce jeu](../player-options) contient toutes les options que vous avez besoin de configurer et
exporter afin d'obtenir un fichier de configuration.
## Que fait la randomisation à ce jeu ?
Les objets sont mélangés entre les différentes localisations du jeu, donc chaque expérience est unique.
Les localisations randomisés incluent les coffres, les objets reçu des PNJ, ainsi que les trésors submergés sous l'eau.
Le randomiseur inclue également des qualités de vie tel qu'un monde entièrement ouvert,
des cinématiques retirées ainsi qu'une vitesse de navigation améliorée, et plus.
## Quelles localisations sont mélangés ?
Seulement les localisations mises en logiques dans les paramètres du monde seront randomisés.
Les localisations restantes dans le jeu auront un rubis jaune.
Celles-ci incluant un message indiquant que la localisation n'est pas randomisé.
## Quel est l'objectif de The Wind Waker ?
Atteindre et battre Ganondorf en haut de la tour de Ganon.
Pour cela, vous aurez besoin des huit morceaux de la Triforce du Courage, l'Excalibur entièrerement ranimée (sauf si ce
sont des épées optionnelles ou en mode sans épée), les flèches de lumières, ainsi que tous les objets nécessaires pour
atteindre Ganondorf.
## A quoi ressemble un objet venant d'un autre monde dans TWW ?
Les objets appartenant aux autres mondes qui ne sont pas TWW sont représentés
par la Lettre de Père (la lettre que Médolie vous donne pour la donner à Komali),
un objet inutilisé dans le randomiseur.
## Que se passe-t-il quand un joueur reçoit un objet ?
Quand le joueur reçoit n'importe quel objet, il sera automatiquement ajouté à l'inventaire de Link.
Link **ne tiendra pas** l'objet au dessus de sa tête comme dans d'autres randomizer de Zelda.
## J'ai besoin d'aide ! Que dois-je faire ?
Référez vous à la [FAQ](https://lagolunatic.github.io/wwrando/faq/) premièrement. Ensuite,
essayez les étapes de résolutions de problèmes dans le [guide de mise en place](/tutorial/The%20Wind%20Waker/setup/en).
Si vous êtes encore bloqué, s'il vous plait poser votre question dans le salon textuel Wind Waker
dans le serveur discord d'Archipelago.
## J'ai ouvert mon jeu dans Dolphin, mais je n'ai aucun de mes items de démarrage !
Vous devez vous connecter à la salle du multiworld pour recevoir vos objets. Cela inclut votre inventaire de départ.
## Problèmes Connus
- Les rubis randomisés freestanding, butins, et appâts seront aussi données au joueur qui récupère l'objet.
L'objet sera bien envoyé mais le joueur qui le collecte recevra une copie supplémentaire.
- Les objets que tiens Link au dessus de sa tête **ne sont pas** randomisés,
comme les rubis allant des trésors venant des cercles lumineux
jusqu'aux récompenses venant des mini-jeux, ne fonctionneront pas.
- Un objet qui reçoit des messages pour des objets progressifs reçu à des localisations
qui s'envoient plus tôt que prévu seront incorrect. Cela n'affecte pas le gameplay.
- Le compteur de quart de cœur dans les messages lorsqu'on reçoit un objet seront faux d'un.
Cela n'affecte pas le gameplay.
- Il a été signalé que l'itemlink peut être buggé.
Ça ne casse en rien le jeu, mais soyez en conscient.
N'hésitez pas à signaler n'importe quel autre problème ou suggestion d'amélioration dans le salon textuel Wind Waker
dans le serveur discord d'Archipelago !
## Astuces et conseils
### Où sont les secrets de donjons trouvés à trouver dans les donjons ?
[Ce document](https://docs.google.com/document/d/1LrjGr6W9970XEA-pzl8OhwnqMqTbQaxCX--M-kdsLos/edit?usp=sharing)
contient des images montrant les différents secrets des donjons.
### Que font exactement les options obscures et de précisions des options de difficultés ?
Les options `logic_obscurity` et `logic_precision` modifient la logique du randomizer
pour mettre différentes astuces et techniques en logique.
[Ce document](https://docs.google.com/spreadsheets/d/14ToE1SvNr9yRRqU4GK2qxIsuDUs9Edegik3wUbLtzH8/edit?usp=sharing)
liste parfaitement les changements qui sont fait. Les options sont progressives donc par exemple,
la difficulté obscure dur inclue les astuces normales et durs.
Certains changements ont besoin de la combinaison des deux options.
Par exemple, pour mettre les canons qui détruisent la porte de la Forteresse Maudite pour vous en logique,
les paramètres obscure et précision doivent tout les deux être mis au moins à normal.
### Quels sont les différents préréglages d'options ?
Quelques préréglages (presets) sont disponibles sur la [page d'options](../player-options) pour votre confort.
- **Tournoi Saison 8**: Ce sont (aussi proche que possible) les paramètres utilisés dans le [Tournoi
Saison 8](https://docs.google.com/document/d/1b8F5DL3P5fgsQC_URiwhpMfqTpsGh2M-KmtTdXVigh4) du serveur WWR Racing.
Ce préréglage contient 4 boss requis (avec le Roi Cuirassé garanti d'être requis),
entrée des donjons randomisées, difficulté obscure dur, et une variété de checks dans l'overworld,
même si la liste d'options progressive peut sembler intimidante.
Ce préréglage exclut également plusieurs localisations et vous fait commencez avec plusieurs objets.
- **Miniblins 2025**: Ce sont (aussi proche que possible) les paramètres utilisés dans la
[Saison 2025 de Miniblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8)
du serveur WWR Racing. Ce préréglage est bien si vous êtes nouveau à The Wind Waker !
Il n'y a pas beaucoup de localisation dans ce monde, et tu as seulement besoin de compléter deux donjons.
Tu commences aussi avec plusieurs objets utiles comme la double magie,
une amélioration de capacité pour votre arc et vos bombes ainsi que six coeurs.
- **Mixed Pools**: Ce sont (aussi proche que possible) les paramètres utilisés dans le
[Tournoi Mixed Pools Co-op](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg)
du serveur WWR Racing.
Ce préréglage contient toutes les entrées randomisés et inclue la plupart des localisations
derrière une entrée randomisé. Il y a aussi plusieurs locations de l'overworld,
étant donnée que ces paramètres sont censés être joué dans une équipe de deux joueurs.
Ce préréglage a aussi six boss requis, mais vu que les pools d'entrées sont randomisés,
les boss peuvent être trouvés n'importe où ! Regarder votre carte de l'océan pour
déterminer quels îles les boss sont.
## Fonctionnalités planifiées
- Type des coffres Dynamique assorties au contenu en fonction des options activés
- Implémentation des indices venant du randomiseur de base (options de placement des indices et des types d'indices)
- Intégration avec le système d'indice d'Archipelago (ex: indices des enchères)
- Support de l'EnergyLink
- Logique de la voile rapide en tant qu'option
- Continuer la correction de bug
## Crédits
Ce randomiseur ne pouvait pas être possible sans l'aide de :
- BigSharkZ: (Dessinateur de l'îcone)
- Celeste (Maëlle): (correction de logique et de fautes d'orthographe, programmation additionnelle)
- Chavu: (document sur les difficultés de logique)
- CrainWWR: (multiworld et assitance sur la mémoire de Dolphin, programmation additionnelle)
- Cyb3R: (référence pour `TWWClient`)
- DeamonHunter: (programmation additionnelle)
- Dev5ter: (Implémentation initiale de l'AP de TWW)
- Gamma / SageOfMirrors: (programmation additionnelle)
- LagoLunatic: (randomiseur de base, assistance additionelle)
- Lunix: (Support Linux, programmation additionnelle)
- mobby45 (Traduction du guide français)
- Mysteryem: (Support du tracker, programmation additionnelle)
- Necrofitz: (documentation additionelle)
- Ouro: (Support du tracker)
- tal (matzahTalSoup): (guide pour les dungeon secrets)
- Tubamann: (programmation additionnelle)
Le logo archipelago © 2022 par Krista Corkos et Christopher Wilson, sous licence
[CC BY-NC 4.0](http://creativecommons.org/licenses/by-nc/4.0/).

View File

@@ -0,0 +1,95 @@
# Guide de mise en place de l'Archipelago de The Wind Waker
Bienvenue dans l'Archipelago The Wind Waker !
Ce guide vous aidera à mettre en place le randomiser et à jouer à votre premier multiworld.
Si vous jouez à The Wind Waker, vous devez suivre quelques étapes simple pour commencer.
## Requis
Vous aurez besoin des choses suivantes pour être capable de jouer à The Wind Waker:
* L'[émulateur Dolphin](https://dolphin-emu.org/download/). **Nous recommendons d'utiliser la dernière version
sortie.**
* Les utilisateurs Linux peuvent utiliser le paquet flatpak
[disponible sur Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu).
* La dernière version du [Randomiser The Wind Waker pour
Archipelago](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2).
* Veuillez noter que cette version est **différente** de celui utilisé pour le randomiser standard. Cette version
est spécifique à Archipelago.
* Une ISO du jeu Zelda The Wind Waker (version Nord Américaine), probablement nommé "Legend of Zelda, The - The Wind
Waker (USA).iso".
De manière optionnelle, vous pouvez également télécharger:
* Le [tracker pour Wind Waker](https://github.com/Mysteryem/ww-poptracker/releases/latest) avec
[PopTracker](https://github.com/black-sliver/PopTracker/releases), qui en est la dépendance.
* Des [modèles de personnages personnalisés pour Wind
Waker](https://github.com/Sage-of-Mirrors/Custom-Wind-Waker-Player-Models) afin de personnaliser votre personnage en
jeu.
## Mise en place d'un YAML
Tous les joueurs jouant à The Wind Waker doivent donner un YAML comportant les paramètres de leur monde
à l'hôte de la salle.
Vous pouvez aller sur la [page d'options The Wind Waker](/games/The%20Wind%20Waker/player-options)
pour générer un YAML avec vos options désirés.
Seulement les localisations catégorisées sous les options activés
sous "Progression Locations" seront randomisés dans votre monde.
Une fois que vous êtes heureux avec vos paramètres,
donnez votre fichier YAML à l'hôte de la salle et procéder à la prochaine étape.
## Connexion à une salle
L'hôte du multiworld vous donnera un lien pour télécharger votre fichier APTWW
ou un zip contenant les fichiers de tout le monde.
Le fichier APTWW doit être nommé `P#_<nom>_XXXXX.aptww`, où `#` est l'identifiant du joueur,
`<nom>` est votre nom de joueur, et `XXXXX` est l'identifiant de la salle.
L'hôte doit également vous donner le nom de la salle du serveur avec le numéro de port.
Une fois que vous êtes prêt, suivez ces étapes pour vous connecter à la salle:
1. Lancer le build AP du Randomiser. Si c'est la première fois que vous ouvrez le randomiser,
vous aurez besoin d'indiquer le chemin vers votre ISO de The Wind Waker et le dossier de sortie pour l'ISO randomisé.
Ceux-ci seront sauvegardé pour la prochaine fois que vous ouvrez le programme.
2. Modifier n'importe quel cosmétique comme vous le voulez avec les ajustements désirés
ainsi que la personnalisation de votre personnage desiré.
3. Pour le fichier APTWW, naviguer et localiser le chemin du fichier.
4. Appuyer sur `Randomize` en bas à droite.
Cela va randomiser et mettre l'ISO dans le dossier de sortie que vous avez renseigné.
Le fichier sera nommé `TWW AP_YYYYY_P# (<nom>).iso`, où `YYYYY` est le numéro de votre seed,
`#` est l'identifiant de votre joueur, et `<nom>` est le nom de votre joueur (nom de slot).
Veuillez vérifier que ces valeurs sont correctes pour votre multiworld.
5. Ouvrez Dolphin et utilisez le pour ouvrir l'iso randomisé.
6. Lancer `ArchipelagoLauncher.exe` (sans le `.exe` sur Linux) et choisissez `The Wind Waker Client`,
Cela va lancer le client texte.
7. Si Dolphin n'est pas encore ouvert, ou que vous n'avez pas encore commencé de nouveau fichier,
vous serez demandé à le faire.
* Une fois que vous avez ouvert votre ISO dans Dolphin, le client doit dire "Dolphin connected successfully.".
8. Connectez-vous à la salle entrant le nom du serveur et son numéro de port en haut et cliquer sur `Connect`.
Pour ceux qui hébergent sur le site web, cela sera `archipelago.gg:<port>`, où `<port>` est le numéro de port.
Si un jeu est hébergé à partir de `ArchipelagoServer.exe` (sans le `.exe` sur Linux),
le numéro de port par défaut est `38281` mais il peut être changé dans le `host.yaml`.
9. Si tu as ouvert ton ISO correspondant au multiworld auquel tu es connecté,
ça doit authentifier ton nom de slot automatiquement quand tu commences une nouveau fichier de sauvegarde.
## Résolutions de problèmes
* Vérifier que vous utilisez la même version d'Archipelago que celui qui a généré le multiworld.
* Vérifier que `tww.apworld` n'est pas dans votre dossier d'installation Archipelago dans le dossier `custom_worlds`.
* Vérifier que vous utiliser la bonne version du build du randomiser que vous utilisez pour la version d'Archipelago.
* Le build doit donner un message d'erreur vous dirigeant vers la bonne version.
Vous pouvez aussi consulter les notes de version des builds AP de TWW
[ici](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2),
afin de voir avec quelles versions d'Archipelago chaque build est compatible avec.
* Ne pas lancer le Launcher d'Archipelago ou Dolphin en tant qu'Administrateur sur Windows.
* Si vous rencontrez des problèmes avec l'authentification,
vérifier que la ROM randomisé est ouverte dans Dolphin et correspond au multiworld auquel vous vous connectez.
* Vérifier que vous n'utilisez aucune triche Dolphin ou que des codes de triches sont activés.
Certains codes peut interférer de manière imprévue avec l'émulation et
rendre la résolution des problèmes compliquées.
* Vérifier que `Modifier la taille de la mémoire émulée` dans Dolphin
(situé sous `Options` > `Configuration` > `Avancé`) est **désactivé**.
* Si le client ne peut pas se connecter à Dolphin, Vérifier que Dolphin est situé sur le même disque qu'Archipelago.
D'après certaines informations, avoir Dolphin sur un disque dur externe cause des problèmes de connexion.
* Vérifier que la `Région de remplacement` dans Dolphin (situé sous `Options` > `Configuration` > `Général`)
est mise à `NTSC-U`.
* Si vous lancez un menu de démarrage de Gamecube personnalisé,
vous aurez besoin de le passer en allant dans `Options` > `Configuration` > `GameCube`
et cocher `Passer le Menu Principal`.

View File

@@ -59,7 +59,7 @@ class V6World(World):
self.multiworld.itempool += filltrinkets
def generate_basic(self):
musiclist_o = [1,2,3,4,9,12]
musiclist_o = [1,2,3,4,9,11,12]
musiclist_s = musiclist_o.copy()
if self.options.music_rando:
self.multiworld.random.shuffle(musiclist_s)