Compare commits

..

2 Commits

Author SHA1 Message Date
Fabian Dill 43100f2c43 Test: add a failing test for big int 2025-08-03 10:54:17 +02:00
Fabian Dill c6df02a355 Core: move MultiServer.py and CommonClient.py to orjson 2025-08-03 08:20:21 +02:00
318 changed files with 19350 additions and 111189 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.11",
"pythonVersion": "3.10",
"pythonPlatform": "Windows",
"executionEnvironments": [
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: '3.11'
python-version: '3.10'
- name: "Install dependencies"
if: env.diff != ''
+2 -2
View File
@@ -22,9 +22,9 @@ env:
# 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: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation
id-token: 'write'
-154
View File
@@ -1,154 +0,0 @@
name: Build and Publish Docker Images
on:
push:
paths:
- "**"
- "!docs/**"
- "!deploy/**"
- "!setup.py"
- "!.gitignore"
- "!.github/workflows/**"
- ".github/workflows/docker.yml"
branches:
- "*"
tags:
- "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
image-name: ${{ steps.image.outputs.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set lowercase image name
id: image
run: |
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set package name
id: package
run: |
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
type=ref,event=branch,enable={{is_not_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=nightly,enable={{is_default_branch}}
- name: Compute final tags
id: final-tags
run: |
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${{ github.ref_name }}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
# Check if latest is already in tags to avoid duplicates
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
tags+=("$full_latest")
fi
fi
fi
# Set multiline output
echo "tags<<EOF" >> $GITHUB_OUTPUT
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- platform: amd64
runner: ubuntu-latest
suffix: amd64
cache-scope: amd64
- platform: arm64
runner: ubuntu-24.04-arm
suffix: arm64
cache-scope: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute suffixed tags
id: tags
run: |
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
suffixed=()
for t in "${tags[@]}"; do
suffixed+=("$t-${{ matrix.suffix }}")
done
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/${{ matrix.platform }}
push: true
tags: ${{ steps.tags.outputs.tags }}
labels: ${{ needs.prepare.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
provenance: false
manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
run: |
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
for tag in "${tag_array[@]}"; do
docker manifest create "$tag" \
"$tag-amd64" \
"$tag-arm64"
docker manifest push "$tag"
done
@@ -12,6 +12,7 @@ env:
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
+3 -3
View File
@@ -5,16 +5,16 @@ name: Release
on:
push:
tags:
- 'v?[0-9]+.[0-9]+.[0-9]*'
- '*.*.*'
env:
ENEMIZER_VERSION: 7.1
# 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: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation
id-token: 'write'
+6 -6
View File
@@ -39,15 +39,15 @@ jobs:
matrix:
os: [ubuntu-latest]
python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'}
- {version: '3.13'}
include:
- python: {version: '3.11'} # old compat
- python: {version: '3.10'} # old compat
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: windows-latest
- python: {version: '3.13'} # current
- python: {version: '3.12'} # current
os: macos-latest
steps:
@@ -75,7 +75,7 @@ jobs:
os:
- ubuntu-latest
python:
- {version: '3.13'} # current
- {version: '3.12'} # current
steps:
- uses: actions/checkout@v4
+2 -6
View File
@@ -261,7 +261,6 @@ class MultiWorld():
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
"skip_if_solo": item_link.get("skip_if_solo", False),
}
for _name, item_link in item_links.items():
@@ -285,8 +284,6 @@ class MultiWorld():
for group_name, item_link in item_links.items():
game = item_link["game"]
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
continue
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
@@ -1574,7 +1571,7 @@ class ItemClassification(IntFlag):
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b00111)
return int(self & 0b0111)
class Item:
@@ -1902,8 +1899,7 @@ class Spoiler:
if self.unreachables:
outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
for unreachable in sorted(self.unreachables)]))
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
if self.paths:
outfile.write('\n\nPaths:\n\n')
+41 -25
View File
@@ -9,7 +9,6 @@ import sys
import typing
import time
import functools
import warnings
import ModuleUpdate
ModuleUpdate.update()
@@ -22,10 +21,11 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
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 NetUtils import (Endpoint, decode, NetworkItem, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus,
SlotType, NetworkPlayer, encode_to_bytes)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
from worlds import network_data_package
import os
import ssl
@@ -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."""
@@ -108,8 +119,8 @@ class ClientCommandProcessor(CommandProcessor):
count = 0
checked_count = 0
lookup = self.ctx.location_names[self.ctx.game]
for location_id, location in lookup.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:
@@ -130,10 +141,11 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
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.
@@ -142,20 +154,23 @@ class ClientCommandProcessor(CommandProcessor):
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
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 name in lookup.values():
self.output(name)
for key in lookup:
self.output(key)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game."""
return self.output_datapackage_part("Item Names")
return self.output_datapackage_part("item_name_to_id", "Item Names")
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
return self.output_datapackage_part("Location Names")
return self.output_datapackage_part("location_name_to_id", "Location Names")
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
@@ -487,10 +502,11 @@ class CommonContext:
""" `msgs` JSON serializable """
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
await self.server.socket.send(encode_to_bytes(msgs))
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
def consume_players_package(self, package: typing.List[NetworkPlayer]):
self.player_names = {network_player.slot: network_player.name for network_player in package
if self.team == network_player.team}
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
@@ -499,11 +515,12 @@ class CommonContext:
def event_invalid_game(self):
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
async def server_auth(self, password_requested: bool = False):
async def server_auth(self, password_requested: bool = False) -> typing.Optional[str]:
if password_requested and not self.password:
logger.info('Enter the password required to join this game:')
self.password = await self.console_input()
return self.password
return None
async def get_username(self):
if not self.auth:
@@ -927,11 +944,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = Version(*version)
ctx.server_version = Version.from_network_dict(args["version"])
if "generator_version" in args:
ctx.generator_version = Version(*args["generator_version"])
ctx.generator_version = Version.from_network_dict(args["generator_version"])
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
f'generator version: {ctx.generator_version.as_simple_string()}, '
f'tags: {", ".join(args["tags"])}')
@@ -1001,9 +1017,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.slot_info.update({int(pid): NetworkSlot.from_network_dict(data) for pid, data in args["slot_info"].items()})
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.consume_players_package([NetworkPlayer.from_network_dict(player) for player in args["players"]])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
@@ -1052,17 +1068,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in args['items']:
ctx.items_received.append(NetworkItem(*item))
ctx.items_received.append(NetworkItem.from_network_dict(item))
ctx.watcher_event.set()
elif cmd == 'LocationInfo':
for item in [NetworkItem(*item) for item in args['locations']]:
for item in [NetworkItem.from_network_dict(item) for item in args['locations']]:
ctx.locations_info[item.location] = item
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "players" in args:
ctx.consume_players_package(args["players"])
ctx.consume_players_package([NetworkPlayer.from_network_dict(player) for player in args["players"]])
if "hint_points" in args:
ctx.hint_points = args['hint_points']
if "checked_locations" in args:
+2 -2
View File
@@ -28,7 +28,7 @@ COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
"setuptools>=75,<81"
setuptools
COPY _speedups.pyx .
COPY intset.h .
@@ -36,7 +36,7 @@ COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim-bookworm AS archipelago
FROM python:3.12-slim AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
+1 -3
View File
@@ -549,12 +549,10 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations and regular_progression:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
# deprioritized items are still not in the mix, so they need to be collected into state first.
# allow_partial should only be set if there is deprioritized progression to fall back on.
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False,
allow_partial=bool(deprioritized_progression))
name="Priority Retry", one_item_per_player=False, allow_partial=True)
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
+24 -30
View File
@@ -166,10 +166,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
args.outputname = seed_name
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {}
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
@@ -196,7 +205,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
args.player_options = {}
erargs.player_options = {}
player = 1
while player <= args.multi:
@@ -209,21 +218,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(args, k)[player] = v
getattr(erargs, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified
if player not in args.name:
if player not in erargs.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
erargs.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
except Exception as e:
@@ -231,10 +240,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
return args, seed
return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]:
@@ -486,22 +495,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
games = requirements.get("game", {})
for game, version in games.items():
if game not in AutoWorldRegister.world_types:
continue
if not version:
raise Exception(f"Invalid version for game {game}: {version}.")
if isinstance(version, str):
version = {"min": version}
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
+9
View File
@@ -0,0 +1,9 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()
+8
View File
@@ -0,0 +1,8 @@
import ModuleUpdate
import Utils
from worlds.kh2.Client import launch
ModuleUpdate.update()
if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client")
launch()
+1 -1
View File
@@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',
+3 -3
View File
@@ -412,10 +412,10 @@ class LinksAwakeningClient():
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID
# The player name table only goes up to 101, so don't go past that
# The player name table only goes up to 100, so don't go past that
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 101:
from_player = 101
if from_player > 100:
from_player = 100
next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
+14 -5
View File
@@ -37,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando
multiworld.plando_options = args.plando_options
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -59,9 +59,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string()} |"
f"Items: {len(cls.item_names):{item_count}} | "
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
del item_count, location_count
@@ -344,7 +342,18 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
# 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"):
base_types_keys = ["er_hint_data"]
# starting with 0.7.0 pre-encode slot data, until then multiserver does it on load
if version_tuple < (0, 7, 0):
base_types_keys.append("slot_data")
else:
for slot, data in multidata["slot_data"].items():
multidata[slot] = NetUtils.encode_to_bytes(data)
assert type(multidata[slot]) is bytes
multidata["minimum_versions"]["server"] = max((0, 7, 0), multidata["minimum_versions"]["server"])
for key in base_types_keys:
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9)
+7 -7
View File
@@ -5,15 +5,15 @@ import multiprocessing
import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 11, 0):
elif sys.version_info < (3, 10, 1):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(
@@ -74,11 +74,11 @@ def update_command():
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
except (AttributeError, ImportError):
except ImportError:
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None:
+63 -87
View File
@@ -31,6 +31,7 @@ if typing.TYPE_CHECKING:
from NetUtils import ServerConnection
import colorama
import orjson
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate
try:
@@ -41,9 +42,9 @@ except ImportError:
import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text, __version__
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus
SlotType, LocationStore, MultiData, Hint, HintStatus, encode_to_bytes
from BaseClasses import ItemClassification
@@ -169,7 +170,7 @@ team_slot = typing.Tuple[int, int]
class Context:
dumper = staticmethod(encode)
dumper = staticmethod(encode_to_bytes)
loader = staticmethod(decode)
simple_options = {"hint_cost": int,
@@ -453,7 +454,7 @@ class Context:
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
@@ -490,6 +491,10 @@ class Context:
self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory
self.slot_data = decoded_obj['slot_data']
for slot, data in self.slot_data.items():
if not isinstance(data, bytes):
data = encode_to_bytes(data)
data = orjson.Fragment(data)
self.slot_data[slot] = data
self.read_data[f"slot_data_{slot}"] = lambda data=data: data
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in decoded_obj["er_hint_data"].items()}
@@ -1135,13 +1140,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
status: HintStatus | None = None) -> typing.List[Hint]:
"""
Collect a new hint for a given item id or name, with a given status.
If status is None (which is the default value), an automatic status will be determined from the item's quality.
"""
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -1157,38 +1157,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
)
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
@@ -1198,16 +1185,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status
if found:
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return []
@@ -1321,8 +1305,7 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
return s
def _cmd_help(self):
@@ -1351,6 +1334,19 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
@@ -1619,6 +1615,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1644,9 +1641,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
game = self.ctx.games[self.client.slot]
@@ -1666,18 +1663,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
)
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
else:
self.output(response)
@@ -1796,11 +1791,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if cmd == 'Connect':
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
'game' not in args:
'game' not in args or "version" not in args:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect',
"original_cmd": cmd}])
return
args["version"] = Version.from_network_dict(args["version"])
errors = set()
if ctx.password and args['password'] != ctx.password:
errors.add('InvalidPassword')
@@ -1816,7 +1811,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
if minver > args['version']:
if minver > args["version"]:
errors.add('IncompatibleVersion')
try:
client.items_handling = args['items_handling']
@@ -1824,7 +1819,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('InvalidItemsHandling')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
if ctx.compatibility == 0 and args['version'] != Version(__version__):
errors.add('IncompatibleVersion')
if errors:
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
@@ -1955,7 +1950,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
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, persist_even_if_found=True)
if locs and create_as_hint:
@@ -1970,16 +1966,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
hints = []
@@ -2247,19 +2233,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
@@ -2381,9 +2354,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item)
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
if hints:
self.ctx.notify_hints(team, hints)
@@ -2417,14 +2390,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
if hints:
self.ctx.notify_hints(team, hints)
else:
+23 -34
View File
@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
import typing
import enum
import warnings
from json import JSONEncoder, JSONDecoder
import orjson
if typing.TYPE_CHECKING:
from websockets import WebSocketServerProtocol as ServerConnection
@@ -78,6 +78,11 @@ class NetworkPlayer(typing.NamedTuple):
alias: str
name: str
@classmethod
def from_network_dict(cls, source: dict):
source.pop("class", None)
return cls(**source)
class NetworkSlot(typing.NamedTuple):
"""Represents a particular slot across teams."""
@@ -86,6 +91,11 @@ class NetworkSlot(typing.NamedTuple):
type: SlotType
group_members: Sequence[int] = () # only populated if type == group
@classmethod
def from_network_dict(cls, source: dict):
source.pop("class", None)
return cls(**source)
class NetworkItem(typing.NamedTuple):
item: int
@@ -94,6 +104,11 @@ class NetworkItem(typing.NamedTuple):
""" Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0
@classmethod
def from_network_dict(cls, source: dict):
source.pop("class", None)
return cls(**source)
def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
if isinstance(obj, tuple) and hasattr(obj, "_fields"): # NamedTuple is not actually a parent class
@@ -128,15 +143,12 @@ def convert_to_base_types(obj: typing.Any) -> _base_types:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
separators=(',', ':'),
).encode
def encode_to_bytes(obj: typing.Any) -> bytes:
return orjson.dumps(_scan_for_TypedTuples(obj), option=orjson.OPT_NON_STR_KEYS)
def encode(obj: typing.Any) -> str:
return _encode(_scan_for_TypedTuples(obj))
return encode_to_bytes(obj).decode()
def get_any_version(data: dict) -> Version:
@@ -144,33 +156,10 @@ def get_any_version(data: dict) -> Version:
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
allowlist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
}
custom_hooks = {
"Version": get_any_version
}
def _object_hook(o: typing.Any) -> typing.Any:
if isinstance(o, dict):
hook = custom_hooks.get(o.get("class", None), None)
if hook:
return hook(o)
cls = allowlist.get(o.get("class", None), None)
if cls:
for key in tuple(o):
if key not in cls._fields:
del (o[key])
return cls(**o)
return o
decode = JSONDecoder(object_hook=_object_hook).decode
def decode(data: str | bytes) -> typing.Any:
if isinstance(data, str):
data = data.encode()
return orjson.loads(data)
class Endpoint:
+2 -6
View File
@@ -1446,7 +1446,6 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
Optional("skip_if_solo"): Or(None, bool),
}
])
@@ -1710,7 +1709,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from jinja2 import Template
from worlds import AutoWorldRegister
from Utils import local_path, __version__, tuplize_version
from Utils import local_path, __version__
full_path: str
@@ -1753,10 +1752,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
+1 -2
View File
@@ -20,6 +20,7 @@ Currently, the following games are supported:
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
@@ -80,8 +81,6 @@ Currently, the following games are supported:
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
* Celeste (Open World)
* Choo-Choo Charles
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
Utils.init_logging("Starcraft2Client", exception_logger="Client")
launch()
+18 -25
View File
@@ -46,10 +46,14 @@ class Version(typing.NamedTuple):
def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
@classmethod
def from_network_dict(cls, source: dict):
source.pop("class", None)
return cls(**source)
__version__ = "0.6.4"
__version__ = "0.6.3"
version_tuple = tuplize_version(__version__)
version = Version(*version_tuple)
is_linux = sys.platform.startswith("linux")
is_macos = sys.platform == "darwin"
@@ -323,13 +327,11 @@ def get_options() -> Settings:
return get_settings()
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
def persistent_store(category: str, key: str, value: typing.Any):
path = user_path("_persistent_storage.yaml")
storage = persistent_load()
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
return # no changes necessary
category_dict = storage.setdefault(category, {})
category_dict[key] = value
path = user_path("_persistent_storage.yaml")
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
@@ -417,11 +419,11 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless
def get_unique_identifier():
common_path = cache_path("common.json")
try:
if os.path.exists(common_path):
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
except FileNotFoundError:
else:
common_file = {}
uuid = None
@@ -431,9 +433,6 @@ def get_unique_identifier():
from uuid import uuid4
uuid = str(uuid4())
common_file["uuid"] = uuid
cache_folder = os.path.dirname(common_path)
os.makedirs(cache_folder, exist_ok=True)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid
@@ -906,7 +905,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
@@ -943,15 +942,15 @@ class DeprecateDict(dict):
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
# original upstream issue: https://github.com/python/cpython/issues/76327
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags # noqa
from subprocess import _args_from_interpreter_flags
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
@@ -978,23 +977,17 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
def _noop() -> None:
pass
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
import multiprocessing
deprecate("Use multiprocessing.freeze_support() instead")
_extend_freeze_support()
multiprocessing.freeze_support()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
+1 -1
View File
@@ -99,11 +99,11 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files
try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp()
except Exception as e:
logging.exception(e)
+2 -2
View File
@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
# trigger endpoint registration
from . import datapackage, generate, room, tracker, user
from . import datapackage, generate, room, user # trigger registration
+7 -19
View File
@@ -1,16 +1,15 @@
import json
import typing
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit, select
from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app, cache
from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, STATE_STARTED, Seed, STATE_ERROR
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@@ -75,23 +74,12 @@ def generate_api():
def wait_seed_api(seed: UUID):
seed_id = seed
seed = Seed.get(id=seed_id)
reply_dict: dict[str, typing.Any] = {"queue_len": get_queue_length()}
if seed:
reply_dict["text"] = "Generation done"
return reply_dict, 201
return {"text": "Generation done"}, 201
generation = Generation.get(id=seed_id)
if not generation:
reply_dict["text"] = "Generation not found"
return reply_dict, 404
return {"text": "Generation not found"}, 404
elif generation.state == STATE_ERROR:
reply_dict["text"] = "Generation failed"
return reply_dict, 500
reply_dict["text"] = "Generation running"
return reply_dict, 202
@cache.memoize(timeout=5)
def get_queue_length() -> int:
return select(generation for generation in Generation if
generation.state == STATE_STARTED or generation.state == STATE_QUEUED).count()
return {"text": "Generation failed"}, 500
return {"text": "Generation running"}, 202
-241
View File
@@ -1,241 +0,0 @@
from datetime import datetime, timezone
from typing import Any, TypedDict
from uuid import UUID
from flask import abort
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
from WebHostLib import cache
from WebHostLib.api import api_endpoints
from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
class PlayerLocationsTotal(TypedDict):
team: int
player: int
total_locations: int
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
for player in players:
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player."""
for team, players in all_players.items():
for player in players:
player_items_received.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
for player in players:
player_checks_done.append(
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints
activity_timers: list[PlayerTimer] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
activity_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for entry in activity_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
connection_timers: list[PlayerTimer] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
connection_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
# find the matching entry
for entry in connection_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
player_status: list[PlayerStatus] = []
"""The current client status for each player."""
for team, players in all_players.items():
for player in players:
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
"total_checks_done": total_checks_done,
"hints": hints,
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
}
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
slot_info = tracker_data.get_slot_info(player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
break
player_locations_total: list[PlayerLocationsTotal] = []
for team, players in all_players.items():
for player in players:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data
+25 -27
View File
@@ -12,11 +12,12 @@ from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__, restricted_dumps
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
@@ -128,39 +129,36 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse()
args.multi = playercount
args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.spoiler = meta["generator_options"].get("spoiler", 0)
args.race = race
args.outputname = seedname
args.outputpath = target.name
args.teams = 1
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
args.skip_prog_balancing = False
args.skip_output = False
args.spoiler_only = False
args.csv_output = False
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.spoiler_only = False
erargs.csv_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items():
if v is not None:
if hasattr(args, k):
getattr(args, k)[player] = v
if hasattr(erargs, k):
getattr(erargs, k)[player] = v
else:
setattr(args, k, {player: v})
setattr(erargs, k, {player: v})
if not args.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"])
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
+1 -1
View File
@@ -3,10 +3,10 @@ import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
+1 -13
View File
@@ -133,15 +133,6 @@ def tutorial(game: str, file: str):
return abort(404)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial_redirect(game: str, file: str, lang: str):
"""
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
"""
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
@app.route('/tutorial/')
@cache.cached()
def tutorial_landing():
@@ -260,10 +251,7 @@ def host_room(room: UUID):
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
+2 -7
View File
@@ -155,9 +155,7 @@ def generate_weighted_yaml(game: str):
options = {}
for key, val in request.form.items():
if val == "_ensure-empty-list":
options[key] = {}
elif "||" not in key:
if "||" not in key:
if len(str(val)) == 0:
continue
@@ -214,11 +212,8 @@ def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if val == "_ensure-empty-list":
options[key] = []
elif options.get(key):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
+1 -2
View File
@@ -1,7 +1,6 @@
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
pony>=0.7.19
waitress>=3.0.2
Flask-Caching>=2.3.0
Flask-Compress>=1.17
+44 -38
View File
@@ -1,43 +1,49 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => {
// Reload tracker every 60 seconds (sync'd)
const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + targetSecond);
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
let getSleepTimeSeconds = () => {
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
};
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
let updateTracker = () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update dynamic sections
updateSection('player-info', fakeDOM);
updateSection('section-filler', fakeDOM);
updateSection('section-terran', fakeDOM);
updateSection('section-zerg', fakeDOM);
updateSection('section-protoss', fakeDOM);
updateSection('section-nova', fakeDOM);
updateSection('section-kerrigan', fakeDOM);
updateSection('section-keys', fakeDOM);
updateSection('section-locations', fakeDOM);
};
ajax.open('GET', url);
ajax.send();
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
continue;
}
category.addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});
+6
View File
@@ -28,6 +28,7 @@
font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif;
text-transform: uppercase;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
text-shadow: 1px 1px 4px #000000;
}
@@ -36,6 +37,7 @@
font-size: 38px;
font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-top: 20px;
margin-bottom: 0.5rem;
@@ -48,6 +50,7 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
text-align: left;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-bottom: 0.5rem;
}
@@ -56,6 +59,7 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 24px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
margin-bottom: 24px;
}
@@ -63,12 +67,14 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 22px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h6, .markdown details summary.h6{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 20px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h4, .markdown h5, .markdown h6{
+136 -255
View File
@@ -1,279 +1,160 @@
*{
margin: 0;
font-family: "JuraBook", monospace;
}
body{
--icon-size: 36px;
--item-class-padding: 4px;
}
a{
color: #1ae;
#player-tracker-wrapper{
margin: 0;
}
/* Section colours */
#player-info{
background-color: #37a;
}
.player-tracker{
max-width: 100%;
}
.tracker-section{
background-color: grey;
}
#terran-items{
background-color: #3a7;
}
#zerg-items{
background-color: #d94;
}
#protoss-items{
background-color: #37a;
}
#nova-items{
background-color: #777;
}
#kerrigan-items{
background-color: #a37;
}
#keys{
background-color: #aa2;
#tracker-table td {
vertical-align: top;
}
/* Sections */
.section-body{
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
padding-bottom: 3px;
}
.section-body-2{
display: flex;
flex-direction: column;
}
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
display: none;
}
.section-title{
position: relative;
border-bottom: 3px solid black;
/* Prevent text selection */
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
input[type="checkbox"]{
position: absolute;
cursor: pointer;
opacity: 0;
z-index: 1;
width: 100%;
height: 100%;
}
.section-title:hover h2{
text-shadow: 0 0 4px #ddd;
}
.f {
display: flex;
overflow: hidden;
.inventory-table-area{
border: 2px solid #000000;
border-radius: 4px;
padding: 3px 10px 3px 10px;
}
/* Acquire item filters */
.tracker-section img{
height: 100%;
width: var(--icon-size);
height: var(--icon-size);
background-color: black;
}
.unacquired, .lvl-0 .f{
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
}
.spacer{
width: var(--icon-size);
height: var(--icon-size);
.inventory-table-area:has(.inventory-table-terran) {
width: 690px;
background-color: #525494;
}
/* Item groups */
.item-class{
display: flex;
flex-flow: column;
justify-content: center;
padding: var(--item-class-padding);
}
.item-class-header{
display: flex;
flex-flow: row;
}
.item-class-upgrades{
/* Note: {display: flex; flex-flow: column wrap} */
/* just breaks on Firefox (width does not scale to content) */
display: grid;
grid-template-rows: repeat(4, auto);
grid-auto-flow: column;
.inventory-table-area:has(.inventory-table-zerg) {
width: 360px;
background-color: #9d60d2;
}
/* Subsections */
.section-toc{
display: flex;
flex-direction: row;
}
.toc-box{
position: relative;
padding-left: 15px;
padding-right: 15px;
}
.toc-box:hover{
text-shadow: 0 0 7px white;
}
.ss-header{
position: relative;
text-align: center;
writing-mode: sideways-lr;
user-select: none;
padding-top: 5px;
font-size: 115%;
}
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
display: none;
}
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
display: none;
}
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
display: none;
}
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
display: none;
}
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
display: none;
}
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
display: none;
}
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
display: none;
}
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
.inventory-table-area:has(.inventory-table-protoss) {
width: 400px;
background-color: #d2b260;
}
/* Progressive items */
.progressive{
max-height: var(--icon-size);
display: contents;
#tracker-table .inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
.lvl-0 > :nth-child(2),
.lvl-0 > :nth-child(3),
.lvl-0 > :nth-child(4),
.lvl-0 > :nth-child(5){
display: none;
}
.lvl-1 > :nth-child(2),
.lvl-1 > :nth-child(3),
.lvl-1 > :nth-child(4),
.lvl-1 > :nth-child(5){
display: none;
}
.lvl-2 > :nth-child(1),
.lvl-2 > :nth-child(3),
.lvl-2 > :nth-child(4),
.lvl-2 > :nth-child(5){
display: none;
}
.lvl-3 > :nth-child(1),
.lvl-3 > :nth-child(2),
.lvl-3 > :nth-child(4),
.lvl-3 > :nth-child(5){
display: none;
}
.lvl-4 > :nth-child(1),
.lvl-4 > :nth-child(2),
.lvl-4 > :nth-child(3),
.lvl-4 > :nth-child(5){
display: none;
}
.lvl-5 > :nth-child(1),
.lvl-5 > :nth-child(2),
.lvl-5 > :nth-child(3),
.lvl-5 > :nth-child(4){
display: none;
.inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
/* Filler item counters */
.item-counter{
display: table;
text-align: center;
padding: var(--item-class-padding);
}
.item-count{
display: table-cell;
vertical-align: middle;
padding-left: 3px;
padding-right: 15px;
.inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
}
/* Hidden items */
.hidden-class:not(:has(img.acquired)){
display: none;
}
.hidden-item:not(.acquired){
display:none;
.inventory-table img.acquired{
filter: none;
background-color: black;
}
/* Keys */
#keys ol, #keys ul{
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
#keys li{
padding-right: 15pt;
.inventory-table .tint-terran img.acquired {
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
}
/* Locations */
#section-locations{
padding-left: 5px;
}
@media only screen and (min-width: 120ch){
#section-locations ul{
columns: 2;
-webkit-columns: 2;
-moz-columns: 2;
}
}
#locations li.checked{
list-style-type: "✔ ";
.inventory-table .tint-protoss img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
}
/* Allowing scrolling down a little further */
.bottom-padding{
min-height: 33vh;
}
.inventory-table .tint-level-1 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
}
.inventory-table .tint-level-2 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
}
.inventory-table .tint-level-3 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
}
.inventory-table div.counted-item {
position: relative;
}
.inventory-table div.item-count {
width: 160px;
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
border: 2px solid #000000;
border-radius: 4px;
background-color: #87b678;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table table{
width: 100%;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
#location-table td:has(.location-column) {
vertical-align: top;
}
#location-table .location-column {
width: 100%;
height: 100%;
}
#location-table .location-column .spacer {
min-height: 24px;
}
.hide {
display: none;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -98,7 +98,7 @@
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% elif get_slot_info(hint.finding_player).type == 2 %}
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
@@ -109,7 +109,7 @@
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% elif get_slot_info(hint.receiving_player).type == 2 %}
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
+5 -5
View File
@@ -45,15 +45,15 @@
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(player) %}
{%- set player_location_data = tracker_data.get_player_locations(player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(player) }}</td>
<td>{{ tracker_data.get_player_name(receiver) }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>
@@ -22,14 +22,14 @@
-%}
<tr>
<td>
{% if get_slot_info(hint.finding_player).type == 2 %}
{% if get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if get_slot_info(hint.receiving_player).type == 2 %}
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
@@ -134,7 +134,6 @@
{% macro OptionList(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
@@ -147,7 +146,6 @@
{% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -171,7 +169,6 @@
{% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -195,7 +192,6 @@
{% macro OptionSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
+22 -22
View File
@@ -11,32 +11,32 @@
<h1>Site Map</h1>
<h2>Base Pages</h2>
<ul>
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
<li><a href="/discord">Discord Link</a></li>
<li><a href="/faq/en">F.A.Q. Page</a></li>
<li><a href="/favicon.ico">Favicon</a></li>
<li><a href="/generate">Generate Game Page</a></li>
<li><a href="/">Homepage</a></li>
<li><a href="/uploads">Host Game Page</a></li>
<li><a href="/datapackage">Raw Data Package</a></li>
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
<li><a href="/sitemap">Site Map</a></li>
<li><a href="/start-playing">Start Playing</a></li>
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
</ul>
<h2>Tutorials</h2>
<ul>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
</ul>
<h2>Game Info Pages</h2>
File diff suppressed because it is too large Load Diff
+4 -15
View File
@@ -30,21 +30,10 @@
}
const data = await response.json();
if (data.queue_len === 1){
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
<p>This is the only generation in the queue.</p>
`;
}
else {
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
<p>There are ${data.queue_len} generations in the queue.</p>
`;
}
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
`;
setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) {
@@ -139,7 +139,6 @@
{% endmacro %}
{% macro OptionList(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry">
@@ -159,7 +158,6 @@
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -182,7 +180,6 @@
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -205,7 +202,6 @@
{% endmacro %}
{% macro OptionSet(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry">
+1153 -243
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -20,8 +20,6 @@ from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
from settings import get_settings
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
@@ -343,12 +341,13 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("ZeldaClient")
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
get_settings()["tloz_options"].get("rom_start", True))
Utils.get_options()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
+5 -2
View File
@@ -220,8 +220,6 @@
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<MessageBox>:
height: self.content.texture_size[1] + 80
<ScrollBox>:
layout: layout
bar_width: "12dp"
@@ -235,3 +233,8 @@
spacing: 10
size_hint_y: None
height: self.minimum_height
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]
-4
View File
@@ -33,10 +33,6 @@ description: {{ yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }}
requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
{%- if world_version != "0.0.0" %}
game:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %}
{%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values.
+7
View File
@@ -0,0 +1,7 @@
author: Nintendo
data: null
game: A Link to the Past
min_format_version: 1
name: Link
format_version: 1
sprite_version: 1
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+6 -9
View File
@@ -21,6 +21,9 @@
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
# Blasphemous
/worlds/blasphemous/ @TRPG0
@@ -39,15 +42,9 @@
# Celeste 64
/worlds/celeste64/ @PoryGone
# Celeste (Open World)
/worlds/celeste_open_world/ @PoryGone
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
# Choo-Choo Charles
/worlds/cccharles/ @Yaranorgoth
# Civilization VI
/worlds/civ6/ @hesto2
@@ -72,9 +69,6 @@
# Faxanadu
/worlds/faxanadu/ @Daivuk
# Final Fantasy (1)
/worlds/ff1/ @Rosalie-A
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
@@ -244,6 +238,9 @@
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
# /worlds/ff1/
# Ocarina of Time
# /worlds/oot/
-18
View File
@@ -62,24 +62,6 @@ if possible.
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
### Launcher Integration
If you have a python client or want to utilize the integration features of the Archipelago Launcher (ex. Slot links in
webhost) you can define a Component to be a part of the Launcher. `LauncherComponents.components` can be appended to
with additional Components in order to automatically add them to the Launcher. Most Components only need a
`display_name` and `func`, but `supports_uri` and `game_name` can be defined to support launching by webhost links,
`icon` and `description` can be used to customize display in the Launcher UI, and `file_identifier` can be used to
launch by file.
Additionally, if you use `func` you have access to LauncherComponent.launch or launch_subprocess to run your
function as a subprocesses that can be utilized side by side other clients.
```py
def my_func(*args: str):
from .client import run_client
LauncherComponent.launch(run_client, name="My Client", args=args)
```
## World
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
+1 -15
View File
@@ -19,21 +19,7 @@ the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__i
## Metadata
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
The current format version has at minimum:
```json
{
"version": 6,
"compatible_version": 5,
"game": "Game Name"
}
```
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An apworld without a world_version is always treated as older than one with a version
No metadata is specified yet.
## Extra Data
+1 -1
View File
@@ -16,7 +16,7 @@ game contributions:
* **Do not introduce unit test failures/regressions.**
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
your changes. Currently, the oldest supported version
is [Python 3.11](https://www.python.org/downloads/release/python-31113/).
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
pushing.
You can turn them on here:
+2 -2
View File
@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
Terrain matching or dungeon shuffle:
```python
def randomize_within_same_group(group: int) -> list[int]:
def randomize_within_same_group(group: int) -> List[int]:
return [group]
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
```
Directional + area shuffle:
```python
def get_target_groups(group: int) -> list[int]:
def get_target_groups(group: int) -> List[int]:
# example group: LEFT | CAVE
# example result: [RIGHT | CAVE, DOOR | CAVE]
direction = group & Groups.DIRECTION_MASK
+6 -7
View File
@@ -79,7 +79,7 @@ Sent to clients when they connect to an Archipelago server.
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
| password | bool | Denoted whether a password is required to join this room. |
| permissions | dict\[str, [Permission](#Permission)\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
| games | list\[str\] | List of games present in this multiworld. |
@@ -662,14 +662,13 @@ class SlotType(enum.IntFlag):
An object representing static information about a slot.
```python
from collections.abc import Sequence
from typing import NamedTuple
import typing
from NetUtils import SlotType
class NetworkSlot(NamedTuple):
class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
group_members: Sequence[int] = [] # only populated if type == group
group_members: typing.List[int] = [] # only populated if type == group
```
### Permission
@@ -687,8 +686,8 @@ class Permission(enum.IntEnum):
### Hint
An object representing a Hint.
```python
from typing import NamedTuple
class Hint(NamedTuple):
import typing
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
location: int
+1 -1
View File
@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
`worlds/alttp/Options.py`
`worlds.alttp.options.py`
### OptionDict
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
+2 -2
View File
@@ -7,10 +7,10 @@ use that version. These steps are for developers or platforms without compiled r
## General
What you'll need:
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
* [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version
* On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available.
* Python 3.13.x is currently the newest supported version
* Python 3.12.x is currently the newest supported version
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections
+1 -1
View File
@@ -28,7 +28,7 @@ if it does not exist.
## Global Settings
All non-world-specific settings are defined directly in settings.py.
Each value needs to have a default. If the default should be `None`, annotate it using `T | None = None`.
Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`.
To access a "global" config value, with correct typing, use one of
```python
-18
View File
@@ -1,18 +0,0 @@
# Shared Cache
Archipelago maintains a shared folder of information that can be persisted for a machine and reused across Libraries.
It can be found at the User Cache Directory for appname `Archipelago` in the `Cache` subfolder
(ex. `%LOCALAPPDATA%/Archipelago/Cache`).
## Common Cache
The Common Cache `common.json` can be used to store any generic data that is expected to be shared across programs
for the same User.
* `uuid`: A UUID identifier used to identify clients as from the same user/machine, to be sent in the Connect packet
## Data Package Cache
The `datapackage` folder in the shared cache folder is used to store datapackages by game and checksum to be reused
in order to save network traffic. The expected structure is `datapackage/Game Name/checksum_value.json` with the
contents of each json file being the no-whitespace datapackage contents.
+2 -10
View File
@@ -15,10 +15,8 @@
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotations where possible for function signatures and class members.
* Use type annotations where appropriate for local variables (e.g. `var: list[int] = []`, or when the
type is hard or impossible to deduce). Clear annotations help developers look up and validate API calls.
* Prefer new style type annotations for new code (e.g. `var: dict[str, str | int]` over
`var: Dict[str, Union[str, int]]`).
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
beginning of a line at the same indentation as the beginning of the line with the open bracket.
```python
@@ -62,9 +60,3 @@
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Semicolons are required after every statement.
## KV
* Style should be defined in `.kv` as much as possible, only Python when unavailable.
* Should follow [our Python style](#python-code) where appropriate (quotation marks, indentation).
* When escaping a line break, add a space between code and backslash.
+6 -5
View File
@@ -82,10 +82,10 @@ overridden. For more information on what methods are available to your class, ch
#### Alternatives to WorldTestBase
Unit tests can also be created using
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) directly. These may be useful
for generating a multiworld under very specific constraints without using the generic world setup, or for testing
portions of your code that can be tested without relying on a multiworld to be created first.
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.
#### Parametrization
@@ -102,7 +102,8 @@ for multiple inputs) the base test. Some important things to consider when attem
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
extra CPU time. Consider using `unittest.TestCase` directly or setting `WorldTestBase.run_default_tests` to False.
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
or setting `WorldTestBase.run_default_tests` to False.
#### Performance Considerations
+1 -211
View File
@@ -16,10 +16,6 @@ Current endpoints:
- [`/status/<suuid:seed>`](#status)
- Room API
- [`/room_status/<suuid:room_id>`](#roomstatus)
- Tracker API
- [`/tracker/<suuid:tracker>`](#tracker)
- [`/static_tracker/<suuid:tracker>`](#statictracker)
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
- User API
- [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds)
@@ -248,212 +244,6 @@ Example:
}
```
## Tracker Endpoints
Endpoints to fetch information regarding players of an active WebHost room with the supplied tracker_ID. The tracker ID
can either be viewed while on a room tracker page, or from the [room's endpoint](#room-endpoints).
### `/tracker/<suuid:tracker>`
<a name=tracker></a>
Will provide a dict of tracker data with the following keys:
- Each player's current alias (`aliases`)
- Will return the name if there is none
- A list of items each player has received as a NetworkItem (`player_items_received`)
- A list of checks done by each player as a list of the location id's (`player_checks_done`)
- The total number of checks done by all players (`total_checks_done`)
- Hints that players have used or received (`hints`)
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`)
Example:
```json
{
"aliases": [
{
"team": 0,
"player": 1,
"alias": "Incompetence"
},
{
"team": 0,
"player": 2,
"alias": "Slot_Name_2"
}
],
"player_items_received": [
{
"team": 0,
"player": 1,
"items": [
[1, 1, 1, 0],
[2, 2, 2, 1]
]
},
{
"team": 0,
"player": 2,
"items": [
[1, 1, 1, 2],
[2, 2, 2, 0]
]
}
],
"player_checks_done": [
{
"team": 0,
"player": 1,
"locations": [
1,
2
]
},
{
"team": 0,
"player": 2,
"locations": [
1,
2
]
}
],
"total_checks_done": [
{
"team": 0,
"checks_done": 4
}
],
"hints": [
{
"team": 0,
"player": 1,
"hints": [
[1, 2, 4, 6, 0, "", 4, 0]
]
},
{
"team": 0,
"player": 2,
"hints": []
}
],
"activity_timers": [
{
"team": 0,
"player": 1,
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
},
{
"team": 0,
"player": 2,
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
}
],
"connection_timers": [
{
"team": 0,
"player": 1,
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
},
{
"team": 0,
"player": 2,
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
}
],
"player_status": [
{
"team": 0,
"player": 1,
"status": 0
},
{
"team": 0,
"player": 2,
"status": 0
}
]
}
```
### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a>
Will provide a dict of static tracker data with the following keys:
- item_link groups and their players (`groups`)
- The datapackage hash for each game (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
- The number of checks found vs. total checks available per player (`player_locations_total`)
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
Example:
```json
{
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
]
}
],
"datapackage": {
"Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
},
"The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
}
},
"player_locations_total": [
{
"player": 1,
"team" : 0,
"total_locations": 10
},
{
"player": 2,
"team" : 0,
"total_locations": 20
}
],
}
```
### `/slot_data_tracker/<suuid:tracker>`
<a name=slotdatatracker></a>
Will provide a list of each player's slot_data.
Example:
```json
[
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
}
}
]
```
## User Endpoints
User endpoints can get room and seed details from the current session tokens (cookies)
@@ -554,4 +344,4 @@ Example:
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
}
]
```
```
+5 -13
View File
@@ -76,8 +76,8 @@ webhost:
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
documents must be prefixed with the same string as defined here. Default already has 'en'.
* `options_presets` (optional) `dict[str, dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `dict[str, Any]` where the keys are the names
* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names
of the options and the values are the values to be set for that option. These presets will be available for users to
select from on the game's options page.
@@ -257,14 +257,6 @@ another flag like "progression", it means "an especially useful progression item
combined with `progression`; see below)
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
* `deprioritized`: denotes that an item should not be placed on priority locations
(to be combined with `progression`; see below)
* `progression_deprioritized`: the combination of `progression` and `deprioritized`, i.e. a progression item that
should not be placed on priority locations, despite being progression;
like skip_balancing, this is commonly used for currency or tokens.
* `progression_deprioritized_skip_balancing`: the combination of `progression`, `deprioritized` and `skip_balancing`.
Since there is overlap between the kind of items that want `skip_balancing` and `deprioritized`,
this combined classification exists for convenience
### Regions
@@ -753,7 +745,7 @@ from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import LogicMixin
class MyGameState(LogicMixin):
mygame_defeatable_enemies: dict[int, set[str]] # per player
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
def init_mixin(self, multiworld: MultiWorld) -> None:
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
@@ -882,11 +874,11 @@ item/location pairs is unnecessary since the AP server already retains and freel
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
```python
def fill_slot_data(self) -> dict[str, Any]:
def fill_slot_data(self) -> Dict[str, Any]:
# In order for our game client to handle the generated seed correctly we need to know what the user selected
# for their difficulty and final boss HP.
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
# The options dataclass has a method to return a `dict[str, Any]` of each option name provided and the relevant
# The options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the relevant
# option's value.
return self.options.as_dict("difficulty", "final_boss_hp")
```
+3 -3
View File
@@ -74,12 +74,13 @@ class EntranceLookup:
if entrance in self._expands_graph_cache:
return self._expands_graph_cache[entrance]
seen = {entrance.connected_region}
visited = set()
q: deque[Region] = deque()
q.append(entrance.connected_region)
while q:
region = q.popleft()
visited.add(region)
# check if the region itself is progression
if region in region.multiworld.indirect_connections:
@@ -102,8 +103,7 @@ class EntranceLookup:
and exit_ in self._usable_exits):
self._expands_graph_cache[entrance] = True
return True
elif exit_.connected_region and exit_.connected_region not in seen:
seen.add(exit_.connected_region)
elif exit_.connected_region and exit_.connected_region not in visited:
q.append(exit_.connected_region)
self._expands_graph_cache[entrance] = False
+3 -1
View File
@@ -720,11 +720,13 @@ class MessageBoxLabel(MDLabel):
class MessageBox(Popup):
def __init__(self, title, text, error=False, **kwargs):
label = MessageBoxLabel(text=text, padding=("6dp", "0dp"))
label = MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class MDNavigationItemBase(MDNavigationItem):
+1 -6
View File
@@ -754,12 +754,7 @@ class Settings(Group):
return super().__getattribute__(key)
# directly import world and grab settings class
world_mod, world_cls_name = _world_settings_name_cache[key].rsplit(".", 1)
try:
world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name))
except AttributeError:
import warnings
warnings.warn(f"World {world_cls_name} failed to initialize properly.")
return super().__getattribute__(key)
world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name))
assert getattr(world, "settings_key") == key
try:
cls_or_name = world.__annotations__["settings"]
+5 -19
View File
@@ -22,7 +22,7 @@ SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fi
# 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.4.0'
requirement = 'cx-Freeze==8.0.0'
try:
import pkg_resources
try:
@@ -30,7 +30,7 @@ try:
install_cx_freeze = False
except pkg_resources.ResolutionError:
install_cx_freeze = True
except (AttributeError, ImportError):
except ImportError:
install_cx_freeze = True
pkg_resources = None # type: ignore[assignment]
@@ -65,6 +65,7 @@ from Cython.Build import cythonize
non_apworlds: set[str] = {
"A Link to the Past",
"Adventure",
"ArchipIDLE",
"Archipelago",
"Lufia II Ancient Cave",
"Meritous",
@@ -371,8 +372,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import APWorldContainer
from Utils import version
assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = []
@@ -381,26 +380,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
if worldname not in non_apworlds:
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"):
manifest = json.load(open(world_directory / "archipelago.json"))
else:
manifest = {}
# this method creates an apworld that cannot be moved to a different OS or minor python version,
# which should be ok
zip_path = self.libfolder / "worlds" / (file_name + ".apworld")
apworld = APWorldContainer(str(zip_path))
apworld.minimum_ap_version = version
apworld.maximum_ap_version = version
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "x", zipfile.ZIP_DEFLATED,
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in world_directory.rglob("*.*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
zf.write(path, relative_path)
folders_to_remove.append(file_name)
shutil.rmtree(world_directory)
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
+3
View File
@@ -0,0 +1,3 @@
from .bases import TestBase, WorldTestBase
from warnings import warn
warn("TestBase was renamed to bases", DeprecationWarning)
+92 -1
View File
@@ -9,7 +9,98 @@ from test.general import gen_steps
from worlds import AutoWorld
from worlds.AutoWorld import World, call_all
from BaseClasses import Location, MultiWorld, CollectionState, Item
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
from worlds.alttp.Items import item_factory
class TestBase(unittest.TestCase):
multiworld: MultiWorld
_state_cache = {}
def get_state(self, items):
if (self.multiworld, tuple(items)) in self._state_cache:
return self._state_cache[self.multiworld, tuple(items)]
state = CollectionState(self.multiworld)
for item in items:
item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True)
state.sweep_for_advancements()
state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state
return state
def get_path(self, state, region):
def flist_to_iter(node):
while node:
value, node = node
yield value
from itertools import zip_longest
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
def run_location_tests(self, access_pool):
for i, (location, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Location reachable without required item", location=location,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
f"{missing_item} removed from: {item_pool}")
def run_entrance_tests(self, access_pool):
for i, (entrance, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
def _get_items(self, item_pool, all_except):
if all_except and len(all_except) > 0:
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)
class WorldTestBase(unittest.TestCase):
+7 -19
View File
@@ -1,9 +1,9 @@
from argparse import Namespace
from typing import Any, List, Optional, Tuple, Type
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package
from worlds.AutoWorld import World, WebWorld, call_all
from worlds.AutoWorld import World, call_all
gen_steps = (
"generate_early",
@@ -17,7 +17,7 @@ gen_steps = (
def setup_solo_multiworld(
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
) -> MultiWorld:
"""
Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps.
@@ -31,8 +31,8 @@ def setup_solo_multiworld(
return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str, ...] = gen_steps,
seed: int | None = None, options: dict[str, Any] | list[dict[str, Any]] = None) -> MultiWorld:
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: Optional[int] = None) -> MultiWorld:
"""
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps.
@@ -40,27 +40,20 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
:param worlds: Type/s of worlds to generate a multiworld for
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
:param seed: The seed to be used when creating this multiworld
:param options: Options to set on each world. If just one dict of options is passed, it will be used for all worlds.
:return: The generated multiworld
"""
if not isinstance(worlds, list):
worlds = [worlds]
if options is None:
options = [{}] * len(worlds)
elif not isinstance(options, list):
options = [options] * len(worlds)
players = len(worlds)
multiworld = MultiWorld(players)
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed)
args = Namespace()
for player, (world_type, option_overrides) in enumerate(zip(worlds, options), 1):
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
updated_options = getattr(args, key, {})
updated_options[player] = option.from_any(option_overrides.get(key, option.default))
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
@@ -69,16 +62,11 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
return multiworld
class TestWebWorld(WebWorld):
tutorials = []
class TestWorld(World):
game = f"Test Game"
item_name_to_id = {}
location_name_to_id = {}
hidden = True
web = TestWebWorld()
# add our test world to the data package, so we can test it later
+2 -3
View File
@@ -48,14 +48,13 @@ class TestBase(unittest.TestCase):
original_get_all_state = multiworld.get_all_state
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False,
**kwargs):
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
self.assertTrue(allow_partial_entrances, (
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
"As such, any call to get_all_state must use allow_partial_entrances = True."
))
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs)
return original_get_all_state(use_cache, allow_partial_entrances)
multiworld.get_all_state = patched_get_all_state
+4 -5
View File
@@ -37,11 +37,10 @@ class TestImplemented(unittest.TestCase):
def test_slot_data(self):
"""Tests that if a world creates slot data, it's json serializable."""
# has an await for generate_output which isn't being called
excluded_games = ("Ocarina of Time",)
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
for game_name, world_type in AutoWorldRegister.world_types.items():
# has an await for generate_output which isn't being called
if game_name in {"Ocarina of Time"}:
continue
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
+2 -1
View File
@@ -150,7 +150,8 @@ class TestBase(unittest.TestCase):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
for game_name, world_type in AutoWorldRegister.world_types.items():
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):
multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy()
+3 -6
View File
@@ -33,10 +33,7 @@ class TestBase(unittest.TestCase):
def test_location_creation_steps(self):
"""Tests that Regions and Locations aren't created after `create_items`."""
gen_steps = ("generate_early", "create_regions", "create_items")
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
region_count = len(multiworld.get_regions())
@@ -57,13 +54,13 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")
self.assertEqual(location_count, len(multiworld.get_locations()),
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during generate_basic")
call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill")
self.assertEqual(location_count, len(multiworld.get_locations()),
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during pre_fill")
def test_location_group(self):
+11
View File
@@ -0,0 +1,11 @@
import orjson
import unittest
from NetUtils import encode, decode
class TestSerialize(unittest.TestCase):
def test_unbounded_int(self) -> None:
big_number = 2**200
round_tripped_big_number = decode(encode(orjson.Fragment(str(big_number).encode())))
self.assertEqual(big_number, round_tripped_big_number)
self.assertEqual(type(big_number), type(round_tripped_big_number))
-63
View File
@@ -1,63 +0,0 @@
import urllib.parse
import html
import re
from flask import url_for
import WebHost
from . import TestBase
class TestSitemap(TestBase):
# Codes for OK and some redirects that we use
valid_status_codes = [200, 302, 308]
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
WebHost.copy_tutorials_files_to_static()
def test_sitemap_route(self) -> None:
"""Verify that the sitemap route works correctly and renders the template without errors."""
with self.app.test_request_context():
# Test the /sitemap route
with self.client.open("/sitemap") as response:
self.assertEqual(response.status_code, 200)
self.assertIn(b"Site Map", response.data)
# Test the /index route which should also serve the sitemap
with self.client.open("/index") as response:
self.assertEqual(response.status_code, 200)
self.assertIn(b"Site Map", response.data)
# Test using url_for with the function name
with self.client.open(url_for('get_sitemap')) as response:
self.assertEqual(response.status_code, 200)
self.assertIn(b'Site Map', response.data)
def test_sitemap_links(self) -> None:
"""
Verify that all links in the sitemap are valid by making a request to each one.
"""
with self.app.test_request_context():
with self.client.open(url_for("get_sitemap")) as response:
self.assertEqual(response.status_code, 200)
html_content = response.data.decode()
# Extract all href links using regex
href_pattern = re.compile(r'href=["\'](.*?)["\']')
links = href_pattern.findall(html_content)
self.assertTrue(len(links) > 0, "No links found in sitemap")
# Test each link
for link in links:
# Skip external links
if link.startswith(("http://", "https://")):
continue
link = urllib.parse.unquote(html.unescape(link))
with self.client.open(link) as response, self.subTest(link=link):
self.assertIn(response.status_code, self.valid_status_codes,
f"Link {link} returned invalid status code {response.status_code}")
-10
View File
@@ -93,13 +93,3 @@ class TestTracker(TestBase):
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
)
self.assertEqual(response.status_code, 400)
def test_tracker_api(self) -> None:
"""Verify that tracker api gives a reply for the room."""
with self.app.test_request_context():
with self.client.open(url_for("api.tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.static_tracker_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
with self.client.open(url_for("api.tracker_slot_data", tracker=self.tracker_uuid)) as response:
self.assertEqual(response.status_code, 200)
+3 -32
View File
@@ -1,46 +1,17 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from unittest import TestLoader, TestSuite
def load_tests(loader: "TestLoader", standard_tests: "TestSuite", pattern: str):
def load_tests(loader, standard_tests, pattern):
import os
import unittest
import fnmatch
from .. import file_path
from worlds.AutoWorld import AutoWorldRegister
suite = unittest.TestSuite()
suite.addTests(standard_tests)
# pattern hack
# all tests from within __init__ are always imported, so we need to filter out the folder earlier
# if the pattern isn't matching a specific world, we don't have much of a solution
if pattern.startswith("worlds."):
if pattern.endswith(".py"):
pattern = pattern[:-3]
components = pattern.split(".")
world_glob = f"worlds.{components[1]}"
pattern = components[-1]
elif pattern.startswith(f"worlds{os.path.sep}") or pattern.startswith(f"worlds{os.path.altsep}"):
components = pattern.split(os.path.sep)
if len(components) == 1:
components = pattern.split(os.path.altsep)
world_glob = f"worlds.{components[1]}"
pattern = components[-1]
else:
world_glob = "*"
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
for world in AutoWorldRegister.world_types.values()
if fnmatch.fnmatch(world.__module__, world_glob)]
for world in AutoWorldRegister.world_types.values()]
all_tests = [
test_case for folder in folders if os.path.exists(folder)
for test_collection in loader.discover(folder, top_level_dir=file_path, pattern=pattern)
for test_collection in loader.discover(folder, top_level_dir=file_path)
for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite)
for test_case in test_suite
]
+1 -11
View File
@@ -12,7 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState
from Utils import Version
from Utils import deprecate
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
@@ -22,10 +22,6 @@ if TYPE_CHECKING:
perf_logger = logging.getLogger("performance")
class InvalidItemError(KeyError):
pass
class AutoWorldRegister(type):
world_types: Dict[str, Type[World]] = {}
__file__: str
@@ -75,10 +71,6 @@ class AutoWorldRegister(type):
if "required_client_version" in base.__dict__:
dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"])
if "world_version" in dct:
if dct["world_version"] != Version(0, 0, 0):
raise RuntimeError(f"{name} is attempting to set 'world_version' from within the class. world_version "
f"can only be set from manifest.")
# construct class
new_class = super().__new__(mcs, name, bases, dct)
@@ -341,8 +333,6 @@ class World(metaclass=AutoWorldRegister):
"""If loaded from a .apworld, this is the Path to it."""
__file__: ClassVar[str]
"""path it was loaded from"""
world_version: ClassVar[Version] = Version(0, 0, 0)
"""Optional world version loaded from archipelago.json"""
def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None
+9 -50
View File
@@ -8,8 +8,7 @@ import os
import threading
from io import BytesIO
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence,
TYPE_CHECKING)
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
import bsdiff4
@@ -17,9 +16,6 @@ semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading
if TYPE_CHECKING:
from Utils import Version
class AutoPatchRegister(abc.ABCMeta):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -69,7 +65,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
return handler
container_version: int = 7
container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int):
@@ -96,7 +92,7 @@ class APContainer:
version: ClassVar[int] = container_version
compression_level: ClassVar[int] = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
manifest_path: str = "archipelago.json"
path: Optional[str]
def __init__(self, path: Optional[str] = None):
@@ -120,7 +116,7 @@ class APContainer:
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr(self.manifest_path, manifest_str)
opened_zipfile.writestr("archipelago.json", manifest_str)
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
@@ -141,18 +137,7 @@ class APContainer:
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
try:
assert self.manifest_path.endswith("archipelago.json"), "Filename should be archipelago.json"
manifest_info = opened_zipfile.getinfo(self.manifest_path)
except KeyError as e:
for info in opened_zipfile.infolist():
if info.filename.endswith("archipelago.json"):
manifest_info = info
self.manifest_path = info.filename
break
else:
raise e
with opened_zipfile.open(manifest_info, "r") as f:
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
@@ -167,33 +152,6 @@ class APContainer:
}
class APWorldContainer(APContainer):
"""A zipfile containing a world implementation."""
game: str | None = None
world_version: "Version | None" = None
minimum_ap_version: "Version | None" = None
maximum_ap_version: "Version | None" = None
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
from Utils import tuplize_version, Version
manifest = super().read_contents(opened_zipfile)
self.game = manifest["game"]
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
if version_key in manifest:
setattr(self, version_key, Version(*tuplize_version(manifest[version_key])))
return manifest
def get_manifest(self) -> Dict[str, Any]:
manifest = super().get_manifest()
manifest["game"] = self.game
manifest["compatible_version"] = 7
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
version = getattr(self, version_key)
if version:
manifest[version_key] = version.as_simple_string()
return manifest
class APPlayerContainer(APContainer):
"""A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None
@@ -290,8 +248,10 @@ class APProcedurePatch(APAutoPatchInterface):
manifest["compatible_version"] = 5
return manifest
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile)
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).read_contents(opened_zipfile)
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if "procedure" not in manifest:
# support patching files made before moving to procedures
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
@@ -300,7 +260,6 @@ class APProcedurePatch(APAutoPatchInterface):
for file in opened_zipfile.namelist():
if file not in ["archipelago.json"]:
self.files[file] = opened_zipfile.read(file)
return manifest
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).write_contents(opened_zipfile)
+8 -41
View File
@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running
from Utils import local_path, open_filename
class Type(Enum):
@@ -177,9 +177,10 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
if module_name == loaded_name:
found_already_loaded = True
break
if found_already_loaded and is_kivy_running():
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, "
"so a Launcher restart is required to use the new installation.")
if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
"so a Launcher restart is required to use the new installation.\n"
"If the Launcher is not open, no action needs to be taken.")
world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source)
world_source.load()
@@ -196,7 +197,7 @@ def install_apworld(apworld_path: str = "") -> None:
source, target = res
except Exception as e:
import Utils
Utils.messagebox("Notice", str(e), error=True)
Utils.messagebox(e.__class__.__name__, str(e), error=True)
logging.exception(e)
else:
import Utils
@@ -228,6 +229,8 @@ components: List[Component] = [
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
@@ -242,39 +245,3 @@ icon_paths = {
'icon': local_path('data', 'icon.png'),
'discord': local_path('data', 'discord-mark-blue.png'),
}
if not is_frozen():
def _build_apworlds():
import json
import os
import zipfile
from worlds import AutoWorldRegister
from worlds.Files import APWorldContainer
apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in AutoWorldRegister.world_types.items():
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
else:
manifest = {}
zip_path = os.path.join(apworlds_folder, file_name + ".apworld")
apworld = APWorldContainer(str(zip_path))
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in pathlib.Path(world_directory).rglob("*.*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
continue
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
components.append(Component('Build apworlds', func=_build_apworlds, cli=True,))
+4 -88
View File
@@ -7,11 +7,10 @@ import warnings
import zipimport
import time
import dataclasses
import json
from typing import List
from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -39,7 +38,6 @@ class WorldSource:
is_zip: bool = False
relative: bool = True # relative to regular world import folder
time_taken: float = -1.0
version: Version = Version(0, 0, 0)
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -104,94 +102,12 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
# import all submodules to trigger AutoWorldRegister
world_sources.sort()
apworlds: list[WorldSource] = []
for world_source in world_sources:
# load all loose files first:
if world_source.is_zip:
apworlds.append(world_source)
else:
world_source.load()
from .AutoWorld import AutoWorldRegister
for world_source in world_sources:
if not world_source.is_zip:
# look for manifest
manifest = {}
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames:
if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r"))
break
if manifest:
break
game = manifest.get("game")
if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_version = Version(*tuplize_version(manifest.get("world_version",
"0.0.0")))
if apworlds:
# encapsulation for namespace / gc purposes
def load_apworlds() -> None:
global apworlds
from .Files import APWorldContainer, InvalidDataError
core_compatible: list[tuple[WorldSource, APWorldContainer]] = []
def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True) -> None:
if add_as_failed_to_load:
failed_world_loads.append(game_name)
logging.warning(reason)
for apworld_source in apworlds:
apworld: APWorldContainer = APWorldContainer(apworld_source.resolved_path)
# populate metadata
try:
apworld.read()
except InvalidDataError as e:
if version_tuple < (0, 7, 0):
logging.error(
f"Invalid or missing manifest file for {apworld_source.resolved_path}. "
"This apworld will stop working with Archipelago 0.7.0."
)
logging.error(e)
else:
raise e
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its minimum core version {apworld.minimum_ap_version} "
f"is higher than current core version {version_tuple}.")
elif apworld.maximum_ap_version and apworld.maximum_ap_version < version_tuple:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its maximum core version {apworld.maximum_ap_version} "
f"is lower than current core version {version_tuple}.")
else:
core_compatible.append((apworld_source, apworld))
# load highest version first
core_compatible.sort(
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
reverse=True)
for apworld_source, apworld in core_compatible:
if apworld.game and apworld.game in AutoWorldRegister.world_types:
fail_world(apworld.game,
f"Did not load {apworld_source.path} "
f"as its game {apworld.game} is already loaded.",
add_as_failed_to_load=False)
else:
apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
# world could fail to load at this point
if apworld.world_version:
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
load_apworlds()
del load_apworlds
del apworlds
world_source.load()
# Build the data package for each game.
from .AutoWorld import AutoWorldRegister
network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
}
+2 -2
View File
@@ -19,7 +19,7 @@ class GameData:
"""
:param data:
"""
self.abilities: Dict[int, AbilityData] = {a.ability_id: AbilityData(self, a) for a in data.abilities if a.available}
self.abilities: Dict[int, AbilityData] = {}
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
@@ -40,7 +40,7 @@ class AbilityData:
self._proto = proto
# What happens if we comment this out? Should this not be commented out? What is its purpose?
# assert self.id != 0 # let the world burn
assert self.id != 0
def __repr__(self) -> str:
return f"AbilityData(name={self._proto.button_name})"
-21
View File
@@ -623,23 +623,6 @@ class ParadeTrapWeight(Range):
default = 20
class DeathLinkAmnesty(Range):
"""Amount of forgiven deaths before sending a Death Link.
0 means that every death will send a Death Link."""
display_name = "Death Link Amnesty"
range_start = 0
range_end = 20
default = 0
class DWDeathLinkAmnesty(Range):
"""Amount of forgiven deaths before sending a Death Link during Death Wish levels."""
display_name = "Death Wish Amnesty"
range_start = 0
range_end = 30
default = 5
@dataclass
class AHITOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
@@ -717,8 +700,6 @@ class AHITOptions(PerGameCommonOptions):
ParadeTrapWeight: ParadeTrapWeight
death_link: DeathLink
death_link_amnesty: DeathLinkAmnesty
dw_death_link_amnesty: DWDeathLinkAmnesty
ahit_option_groups: Dict[str, List[Any]] = {
@@ -788,6 +769,4 @@ slot_data_options: List[str] = [
"MaxPonCost",
"death_link",
"death_link_amnesty",
"dw_death_link_amnesty",
]
+22
View File
@@ -0,0 +1,22 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState
from worlds import AutoWorldRegister
class LTTPTestBase(unittest.TestCase):
def world_setup(self):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
self.multiworld.set_seed(None)
args = Namespace()
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[1]
# by default medallion access is randomized, for unittests we set it to vanilla
self.world.options.misery_mire_medallion.value = Medallion.option_ether
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
-113
View File
@@ -1,113 +0,0 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld, CollectionState, ItemClassification
from worlds import AutoWorldRegister
from ..Items import item_factory
class TestBase(unittest.TestCase):
multiworld: MultiWorld
_state_cache = {}
def get_state(self, items):
if (self.multiworld, tuple(items)) in self._state_cache:
return self._state_cache[self.multiworld, tuple(items)]
state = CollectionState(self.multiworld)
for item in items:
item.classification = ItemClassification.progression
state.collect(item, prevent_sweep=True)
state.sweep_for_advancements()
state.update_reachable_regions(1)
self._state_cache[self.multiworld, tuple(items)] = state
return state
def get_path(self, state, region):
def flist_to_iter(node):
while node:
value, node = node
yield value
from itertools import zip_longest
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
def run_location_tests(self, access_pool):
for i, (location, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Location reachable without required item", location=location,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
f"{missing_item} removed from: {item_pool}")
def run_entrance_tests(self, access_pool):
for i, (entrance, access, *item_pool) in enumerate(access_pool):
items = item_pool[0]
all_except = item_pool[1] if len(item_pool) > 1 else None
state = self._get_items(item_pool, all_except)
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
all_except=all_except, path=path, entry=i):
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
# check for partial solution
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
for missing_item in item_pool[0]:
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
items=item_pool[0], missing_item=missing_item, entry=i):
state = self._get_items_partial(item_pool, missing_item)
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
def _get_items(self, item_pool, all_except):
if all_except and len(all_except) > 0:
items = self.multiworld.itempool[:]
items = [item for item in items if
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
else:
items = item_factory(item_pool[0], self.multiworld.worlds[1])
return self.get_state(items)
def _get_items_partial(self, item_pool, missing_item):
new_items = item_pool[0].copy()
new_items.remove(missing_item)
items = item_factory(new_items, self.multiworld.worlds[1])
return self.get_state(items)
class LTTPTestBase(unittest.TestCase):
def world_setup(self):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
self.multiworld.set_seed(None)
args = Namespace()
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[1]
# by default medallion access is randomized, for unittests we set it to vanilla
self.world.options.misery_mire_medallion.value = Medallion.option_ether
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
+1 -1
View File
@@ -5,7 +5,7 @@ from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import create_regions
from worlds.alttp.Shops import create_shops
from worlds.alttp.test.bases import LTTPTestBase
from worlds.alttp.test import LTTPTestBase
class TestDungeon(LTTPTestBase):
+9 -8
View File
@@ -1,12 +1,13 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInverted(TestBase, LTTPTestBase):
@@ -4,7 +4,7 @@ from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Rules import set_inverted_big_bomb_rules
from worlds.alttp.test.bases import LTTPTestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedBombRules(LTTPTestBase):
@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedMinor(TestBase, LTTPTestBase):
@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ...Regions import mark_light_world_regions
from ...Shops import create_shops
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
from test.bases import TestBase
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestInvertedOWG(TestBase, LTTPTestBase):
+2 -2
View File
@@ -1,5 +1,5 @@
from ...ItemPool import difficulties
from ..bases import TestBase
from worlds.alttp.ItemPool import difficulties
from test.bases import TestBase
base_items = 41
extra_counts = (15, 15, 10, 5, 25)
@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestMinor(TestBase, LTTPTestBase):
+7 -6
View File
@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.test import LTTPTestBase
class TestVanillaOWG(TestBase, LTTPTestBase):
+2 -2
View File
@@ -1,5 +1,5 @@
from ...Shops import shop_table
from ..bases import TestBase
from worlds.alttp.Shops import shop_table
from test.bases import TestBase
class TestSram(TestBase):
+7 -7
View File
@@ -1,10 +1,10 @@
from ...Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties
from ...Items import item_factory
from ...Options import GlitchesRequired
from ..bases import LTTPTestBase, TestBase
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory
from test.bases import TestBase
from worlds.alttp.Options import GlitchesRequired
from worlds.alttp.test import LTTPTestBase
class TestVanilla(TestBase, LTTPTestBase):
+315
View File
@@ -0,0 +1,315 @@
item_table = (
'An Old GeoCities Profile',
'Very Funny Joke',
'Motivational Video',
'Staples Easy Button',
'One Million Dollars',
'Replica Master Sword',
'VHS Copy of Jurassic Park',
'32GB USB Drive',
'Pocket Protector',
'Leftover Parts from IKEA Furniture',
'Half-Empty Ink Cartridge for a Printer',
'Watch Battery',
'Towel',
'Scarf',
'2012 Magic the Gathering Core Set Starter Box',
'Poke\'mon Booster Pack',
'USB Speakers',
'Eco-Friendly Spork',
'Cheeseburger',
'Brand New Car',
'Hunting Knife',
'Zippo Lighter',
'Red Shirt',
'One-Up Mushroom',
'Nokia N-GAGE',
'2-Liter of Sprite',
'Free trial of the critically acclaimed MMORPG Final Fantasy XIV, including the entirety of A Realm Reborn and the award winning Heavensward and Stormblood expansions up to level 70 with no restrictions on playtime!',
'Can of Compressed Air',
'Striped Kitten',
'USB Power Adapter',
'Fortune Cookie',
'Nintendo Power Glove',
'The Lampshade of No Real Significance',
'Kneepads of Allure',
'Get Out of Jail Free Card',
'Box Set of Stargate SG-1 Season 4',
'The Missing Left Sock',
'Poster Tube',
'Electronic Picture Frame',
'Bottle of Shampoo',
'Your Mission, Should You Choose To Accept It',
'Fanny Pack',
'Robocop T-Shirt',
'Suspiciously Small Monocle',
'Table Saw',
'Cookies and Cream Milkshake',
'Deflated Accordion',
'Grandma\'s Homemade Pie',
'Invisible Lego on the Floor',
'Pitfall Trap',
'Flathead Screwdriver',
'Leftover Pizza',
'Voodoo Doll that Looks Like You',
'Pink Shoelaces',
'Half a Bottle of Scotch',
'Reminder Not to Forget Aginah',
'Medicine Ball',
'Yoga Mat',
'Chocolate Orange',
'Old Concert Tickets',
'The Pick of Destiny',
'McGuffin',
'Just a Regular McMuffin',
'34 Tacos',
'Duct Tape',
'Copy of Untitled Goose Game',
'Partially Used Bed Bath & Beyond Gift Card',
'Mostly Popped Bubble Wrap',
'Expired Driver\'s License',
'The Look, You Know the One',
'Transformers Lunch Box',
'MP3 Player',
'Dry Sharpie',
'Chalkboard Eraser',
'Overhead Projector',
'Physical Copy of the Japanese 1.0 Link to the Past',
'Collectable Action Figure',
'Box Set of The Lord of the Rings Books',
'Lite-Bright',
'Stories from the Good-Old-Days',
'Un-Reproducable Bug Reports',
'Autographed Copy of Shaq-Fu',
'Game-Winning Baseball',
'Portable Battery Bank',
'Blockbuster Membership Card',
'Offensive Bumper Sticker',
'Last Sunday\'s Crossword Puzzle',
'Rubik\'s Cube',
'Your First Grey Hair',
'Embarrassing Childhood Photo',
'Abandoned Sphere One Check',
'The Internet',
'Late-Night Cartoons',
'The Correct Usage of a Semicolon',
'Microsoft Windows 95 Resource Kit',
'Car-Phone',
'Walkman Radio',
'Relevant XKCD Comic',
'Razor Scooter',
'Set of Beyblades',
'Box of Pogs',
'Beanie-Baby Collection',
'Laser Tag Gun',
'Radio Controlled Car',
'Boogie Board',
'Air Jordans',
'Rubber Duckie',
'The Last Cookie in the Cookie Jar',
'Tin-Foil Hat',
'Button-Up Shirt',
'Designer Brand Bag',
'Trapper Keeper',
'Fake Moustache',
'Colored Pencils',
'Pair of 3D Glasses',
'Pair of Movie Tickets',
'Refrigerator Magnets',
'NASCAR Dinner Plates',
'The Final Boss',
'Unskippable Cutscenes',
'24 Rolls of Toilet Paper',
'Canned Soup',
'Warm Blanket',
'3D Printer',
'Jetpack',
'Hoverboard',
'Joycons with No Drift',
'Double Rainbow',
'Ping Pong Ball',
'Area 51 Arcade Cabinet',
'Elephant in the Room',
'The Pink Panther',
'Denim Shorts',
'Tennis Racket',
'Collection of Stuffed Animals',
'Old Cell Phone',
'Nintendo Virtual Boy',
'Box of 5.25 Inch Floppy Disks',
'Bag of Miscellaneous Wires',
'Garden Shovel',
'Leather Gloves',
'Knife of +9 VS Ogres',
'Old, Smelly Cheese',
'Linksys BEFSR41 Router',
'Ethernet Cables for a LAN Party',
'Mechanical Pencil',
'Book of Graph Paper',
'300 Sheets of Printer Paper',
'One AAA Battery',
'Box of Old Game Controllers',
'Sega Dreamcast',
'Mario\'s Overalls',
'Betamax Player',
'Stray Lego',
'Chocolate Chip Pancakes',
'Two Blueberry Muffins',
'Nintendo 64 Controller with a Perfect Thumbstick',
'Cuckoo Crossing the Road',
'One Eyed, One Horned, Flying Purple People-Eater',
'Love Potion Number Nine',
'Wireless Headphones',
'Festive Keychain',
'Bundle of Twisted Cables',
'Plank of Wood',
'Broken Ant Farm',
'Thirty-six American Dollars',
'Can of Shaving Cream',
'Blue Hair Dye',
'Mug Engraved with the AP Logo',
'Tube of Toothpaste',
'Album of Elevator Music',
'Headlight Fluid',
'Tickets to the Renaissance Faire',
'Bag of Golf Balls',
'Box of Packing Peanuts',
'Bottle of Peanut Butter',
'Breath of the Wild Cookbook',
'Stardew Valley Cookbook',
'Thirteen Angry Chickens',
'Bowl of Cereal',
'Rubber Snake',
'Stale Sunflower Seeds',
'Alarm Clock Without a Snooze Button',
'Wet Pineapple',
'Set of Scented Candles',
'Adorable Stuffed Animal',
'The Broodwitch',
'Old Photo Album',
'Trade Quest Item',
'Pair of Fancy Boots',
'Shoddy Pickaxe',
'Adventurer\'s Sword',
'Cute Puppy',
'Box of Matches',
'Set of Allen Wrenches',
'Glass of Water',
'Magic Shaggy Carpet',
'Macaroni and Cheese',
'Chocolate Chip Cookie Dough Ice Cream',
'Fresh Strawberries',
'Delicious Tacos',
'The Krabby Patty Recipe',
'Map to Waldo\'s Location',
'Stray Cat',
'Ham and Cheese Sandwich',
'DVD Player',
'Motorcycle Helmet',
'Fake Flowers',
'6-Pack of Sponges',
'Heated Pants',
'Empty Glass Bottle',
'Brown Paper Bag',
'Model Train Set',
'TV Remote',
'RC Car',
'Super Soaker 9000',
'Giant Sunglasses',
'World\'s Smallest Violin',
'Pile of Fresh Warm Laundry',
'Half-Empty Ice Cube Tray',
'Bob Ross Afro Wig',
'Empty Cardboard Box',
'Packet of Soy Sauce',
'Solutions to a Math Test',
'Pencil Eraser',
'The Great Pumpkin',
'Very Expensive Toaster',
'Pack of Colored Sharpies',
'Bag of Chocolate Chips',
'Grandma\'s Homemade Cookies',
'Collection of Bottle Caps',
'Pack of Playing Cards',
'Boom Box',
'Toy Sail Boat',
'Smooth Nail File',
'Colored Chalk',
'Missing Button',
'Rubber Band Ball',
'Joystick',
'Galaga Arcade Cabinet',
'Anime Mouse Pad',
'Orange and Yellow Glow Sticks',
'Odd Bookmark',
'Stray Dice',
'Tooth Picks',
'Dirty Dishes',
'Poke\'mon Card Game Rule Book (Gen 1)',
'Salt Shaker',
'Digital Thermometer',
'Infinite Improbability Drive',
'Fire Extinguisher',
'Beeping Smoke Alarm',
'Greasy Spatula',
'Progressive Auto Insurance',
'Mace Windu\'s Purple Lightsaber',
'An Old Fixer-Upper',
'Gamer Chair',
'Comfortable Reclining Chair',
'Shirt Covered in Dog Hair',
'Angry Praying Mantis',
'Card Games on Motorcycles',
'Trucker Hat',
'The DK Rap',
'Three Great Balls',
'Some Very Sus Behavior',
'Glass of Orange Juice',
'Turkey Bacon',
'Bald Barbie Doll',
'Developer Commentary',
'Subscription to Nintendo Power Magazine',
'DeLorean Time Machine',
'Unkillable Cockroach',
'Dungeons & Dragons Rulebook',
'Boxed Copy of Quest 64',
'James Bond\'s Gadget Wristwatch',
'Tube of Go-Gurt',
'Digital Watch',
'Laser Pointer',
'The Secret Cow Level',
'AOL Free Trial CD-ROM',
'E.T. for Atari 2600',
'Season 2 of Knight Rider',
'Spam E-Mails',
'Half-Life 3 Release Date',
'Source Code of Jurassic Park',
'Moldy Cheese',
'Comic Book Collection',
'Hardcover Copy of Scott Pilgrim VS the World',
'Old Gym Shorts',
'Very Cool Sunglasses',
'Your High School Yearbook Picture',
'Written Invitation to Prom',
'The Star Wars Holiday Special',
'Oil Change Coupon',
'Finger Guns',
'Box of Tabletop Games',
'Sock Puppets',
'The Dog of Wisdom',
'Surprised Chipmunk',
'Stonks',
'A Shrubbery',
'Roomba with a Knife',
'Wet Cat',
'The missing moderator, Frostwares',
'1,793 Crossbows',
'Holographic First Edition Charizard (Gen 1)',
'VR Headset',
'Archipelago 1.0 Release Date',
'Strand of Galadriel\'s Hair',
'Can of Meow-Mix',
'Shake-Weight',
'DVD Collection of Billy Mays Infomercials',
'Old CD Key',
)

Some files were not shown because too many files have changed in this diff Show More