mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-22 07:35:37 -07:00
Merge branch 'ArchipelagoMW:main' into Satisfactory_ToBeVerified
This commit is contained in:
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@@ -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: |
|
||||
|
||||
166
BaseClasses.py
166
BaseClasses.py
@@ -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:
|
||||
|
||||
164
CommonClient.py
164
CommonClient.py
@@ -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:
|
||||
|
||||
@@ -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
47
Main.py
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
39
NetUtils.py
39
NetUtils.py
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
12
Utils.py
12
Utils.py
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 .`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
setup.py
7
setup.py
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
142
worlds/tww/docs/fr_The Wind Waker.md
Normal file
142
worlds/tww/docs/fr_The Wind Waker.md
Normal 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/).
|
||||
95
worlds/tww/docs/setup_fr.md
Normal file
95
worlds/tww/docs/setup_fr.md
Normal 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`.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user