Compare commits

..

1 Commits

Author SHA1 Message Date
NewSoupVi
89ccce7805 Docs: Add deprioritized to AP API doc
Did this on my phone while in the bathroom :)
2025-08-23 22:31:57 +02:00
344 changed files with 19636 additions and 112337 deletions

View File

@@ -9,14 +9,12 @@ on:
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch: workflow_dispatch:
env: env:

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

View File

@@ -12,6 +12,7 @@ env:
jobs: jobs:
labeler: labeler:
name: 'Apply content-based labels' name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v5 - uses: actions/labeler@v5

View File

@@ -5,7 +5,7 @@ name: Release
on: on:
push: push:
tags: tags:
- 'v?[0-9]+.[0-9]+.[0-9]*' - '*.*.*'
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1

View File

@@ -41,13 +41,12 @@ jobs:
python: python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10 - {version: '3.11.2'} # Change to '3.11' around 2026-06-10
- {version: '3.12'} - {version: '3.12'}
- {version: '3.13'}
include: include:
- python: {version: '3.11'} # old compat - python: {version: '3.11'} # old compat
os: windows-latest os: windows-latest
- python: {version: '3.13'} # current - python: {version: '3.12'} # current
os: windows-latest os: windows-latest
- python: {version: '3.13'} # current - python: {version: '3.12'} # current
os: macos-latest os: macos-latest
steps: steps:
@@ -75,7 +74,7 @@ jobs:
os: os:
- ubuntu-latest - ubuntu-latest
python: python:
- {version: '3.13'} # current - {version: '3.12'} # current
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -261,7 +261,6 @@ class MultiWorld():
"local_items": set(item_link.get("local_items", [])), "local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])), "non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]), "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(): for _name, item_link in item_links.items():
@@ -285,8 +284,6 @@ class MultiWorld():
for group_name, item_link in item_links.items(): for group_name, item_link in item_links.items():
game = item_link["game"] 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_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"] group["item_pool"] = item_link["item_pool"]
@@ -1346,7 +1343,8 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now. for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance) return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None: def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[type[Location]] = None) -> None:
""" """
Adds locations to the Region object, where location_type is your Location class and locations is a dict of Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address. location names to address.
@@ -1434,8 +1432,8 @@ class Region:
entrance.connect(self) entrance.connect(self)
return entrance return entrance
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]: rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
""" """
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1443,7 +1441,7 @@ class Region:
created entrances will be named "self.name -> connecting_region" created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule} :param rules: rules for the exits from this region. format is {"connecting_region": rule}
""" """
if not isinstance(exits, Mapping): if not isinstance(exits, Dict):
exits = dict.fromkeys(exits) exits = dict.fromkeys(exits)
return [ return [
self.connect( self.connect(
@@ -1857,9 +1855,6 @@ class Spoiler:
Utils.__version__, self.multiworld.seed)) Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm) outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players) outfile.write('Players: %d\n' % self.multiworld.players)
if self.multiworld.players > 1:
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n') outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile) AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1868,9 +1863,6 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player]) outfile.write('Game: %s\n' % self.multiworld.game[player])
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
outfile.write('Location Count: %d\n' % loc_count)
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option) write_option(f_option, option)

View File

@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"}) self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True 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: def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state. """List all missing location checks, from your local game state.
Can be given text, which will be used as filter.""" Can be given text, which will be used as filter."""
@@ -108,8 +119,8 @@ class ClientCommandProcessor(CommandProcessor):
count = 0 count = 0
checked_count = 0 checked_count = 0
lookup = self.ctx.location_names[self.ctx.game] lookup = self.get_current_datapackage().get("location_name_to_id", {})
for location_id, location in lookup.items(): for location, location_id in lookup.items():
if filter_text and filter_text not in location: if filter_text and filter_text not in location:
continue continue
if location_id < 0: if location_id < 0:
@@ -130,10 +141,11 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.") self.output("No missing location checks found.")
return True 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. 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. :param name: Printed to the user as context for the part.
:return: Whether the process was successful. :return: Whether the process was successful.
@@ -142,20 +154,23 @@ class ClientCommandProcessor(CommandProcessor):
self.output(f"No game set, cannot determine {name}.") self.output(f"No game set, cannot determine {name}.")
return False return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names lookup = self.get_current_datapackage().get(key)
lookup = lookup[self.ctx.game] if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
self.output(f"{name} for {self.ctx.game}") self.output(f"{name} for {self.ctx.game}")
for name in lookup.values(): for key in lookup:
self.output(name) self.output(key)
return True return True
def _cmd_items(self) -> bool: def _cmd_items(self) -> bool:
"""List all item names for the currently running game.""" """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: def _cmd_locations(self) -> bool:
"""List all location names for the currently running game.""" """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"], def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str, filter_key: str,
@@ -856,9 +871,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address) server_url = urllib.parse.urlparse(address)
if server_url.username: if server_url.username:
ctx.username = urllib.parse.unquote(server_url.username) ctx.username = server_url.username
if server_url.password: if server_url.password:
ctx.password = urllib.parse.unquote(server_url.password) ctx.password = server_url.password
def reconnect_hint() -> str: def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else "" return ", type /connect to reconnect" if ctx.server_address else ""

View File

@@ -129,10 +129,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
for i, location in enumerate(placements)) for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts: for (i, location, unsafe) in swap_attempts:
placed_item = location.item placed_item = location.item
if item_to_place == placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the # Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this # number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe] swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
@@ -553,12 +549,10 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations and regular_progression: if prioritylocations and regular_progression:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization # 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. # 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) priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression, fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False, name="Priority Retry", one_item_per_player=False, allow_partial=True)
allow_partial=bool(deprioritized_progression))
if prioritylocations and deprioritized_progression: if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations. # There are no more regular progression items that can be placed on any priority locations.

View File

@@ -166,10 +166,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"A mix is also permitted.") f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
args.outputname = seed_name from worlds.alttp.EntranceRandomizer import parse_arguments
args.sprite = dict.fromkeys(range(1, args.multi+1), None) erargs = parse_arguments(['--multi', str(args.multi)])
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None) erargs.seed = seed
args.name = {} 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, ...]] = \ settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {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): for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path) player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
args.player_options = {} erargs.player_options = {}
player = 1 player = 1
while player <= args.multi: while player <= args.multi:
@@ -209,21 +218,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
for k, v in vars(settingsObject).items(): for k, v in vars(settingsObject).items():
if v is not None: if v is not None:
try: try:
getattr(args, k)[player] = v getattr(erargs, k)[player] = v
except AttributeError: except AttributeError:
setattr(args, k, {player: v}) setattr(erargs, k, {player: v})
except Exception as e: except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified # name was not specified
if player not in args.name: if player not in erargs.name:
if path == args.weights_file_path: if path == args.weights_file_path:
# weights file, so we need to make the name unique # weights file, so we need to make the name unique
args.name[player] = f"Player{player}" erargs.name[player] = f"Player{player}"
else: else:
# use the filename # use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0] erargs.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] = handle_name(erargs.name[player], player, name_counter)
player += 1 player += 1
except Exception as e: except Exception as e:
@@ -231,10 +240,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(name.lower() for name in args.name.values())) != len(args.name): 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 args.name.values())}") 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, ...]: 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: if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, " raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.") 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() ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints: for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints: if option_key in weights and option_key not in Options.CommonOptions.type_hints:

9
KH1Client.py Normal file
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
KH2Client.py Normal file
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()

View File

@@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__': if __name__ == '__main__':
init_logging('Launcher') init_logging('Launcher')
multiprocessing.freeze_support() Utils.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Archipelago Launcher', description='Archipelago Launcher',

View File

@@ -3,6 +3,9 @@ ModuleUpdate.update()
import Utils import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio import asyncio
import base64 import base64
import binascii import binascii
@@ -23,14 +26,16 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) server_loop)
from NetUtils import ClientStatus from NetUtils import ClientStatus
from . import LinksAwakeningWorld from worlds.ladx import LinksAwakeningWorld
from .Common import BASE_ID as LABaseID from worlds.ladx.Common import BASE_ID as LABaseID
from .GpsTracker import GpsTracker from worlds.ladx.GpsTracker import GpsTracker
from .TrackerConsts import storage_key from worlds.ladx.TrackerConsts import storage_key
from .ItemTracker import ItemTracker from worlds.ladx.ItemTracker import ItemTracker
from .LADXR.checkMetadata import checkMetadataTable from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from .Locations import get_locations_to_id, meta_to_name from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from .Tracker import LocationTracker, MagpieBridge, Check from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
class GameboyException(Exception): class GameboyException(Exception):
pass pass
@@ -407,10 +412,10 @@ class LinksAwakeningClient():
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0] status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID 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 # Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 101: if from_player > 100:
from_player = 101 from_player = 100
next_index += 1 next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [ self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
@@ -755,44 +760,42 @@ def run_game(romfile: str) -> None:
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Couldn't launch ROM, {args[0]} is missing") logger.error(f"Couldn't launch ROM, {args[0]} is missing")
def launch(*launch_args): async def main():
async def main(): parser = get_base_parser(description="Link's Awakening Client.")
parser = get_base_parser(description="Link's Awakening Client.") parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--url", help="Archipelago connection url") parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") parser.add_argument('diff_file', default="", type=str, nargs="?",
parser.add_argument('diff_file', default="", type=str, nargs="?", help='Path to a .apladx Archipelago Binary Patch file')
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args(launch_args) args = parser.parse_args()
if args.diff_file: if args.diff_file:
import Patch import Patch
logger.info("patch file was supplied - creating rom...") logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file) meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect: if "server" in meta and not args.connect:
args.connect = meta["server"] args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}") logger.info(f"wrote rom file to {rom_file}")
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda # TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop()) ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled: if gui_enabled:
ctx.run_gui() ctx.run_gui()
ctx.run_cli() ctx.run_cli()
# Down below run_gui so that we get errors out of the process # Down below run_gui so that we get errors out of the process
if args.diff_file: if args.diff_file:
run_game(rom_file) run_game(rom_file)
await ctx.exit_event.wait() await ctx.exit_event.wait()
await ctx.shutdown() await ctx.shutdown()
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

13
Main.py
View File

@@ -37,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger() logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) 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.game = args.game.copy()
multiworld.player_name = args.name.copy() multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy() multiworld.sprite = args.sprite.copy()
@@ -54,17 +54,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
world_classes = AutoWorld.AutoWorldRegister.world_types.values() item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: " logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"v{cls.world_version.as_simple_string():{version_count}} | "
f"Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}") f"Locations: {len(cls.location_names):{location_count}}")
del item_count, location_count del item_count, location_count

View File

@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
import colorama import colorama
import websockets import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory from websockets.extensions.permessage_deflate import PerMessageDeflate
try: try:
# ponyorm is a requirement for webhost, not default server, so may not be importable # ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError from pony.orm.dbapiprovider import OperationalError
@@ -50,15 +50,6 @@ from BaseClasses import ItemClassification
min_client_version = Version(0, 5, 0) min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
no_version = Version(0, 0, 0)
assert isinstance(no_version, tuple) # assert immutable
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
server_max_window_bits=11,
client_max_window_bits=11,
compress_settings={"memLevel": 4},
)
def remove_from_list(container, value): def remove_from_list(container, value):
try: try:
@@ -134,31 +125,8 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
__slots__ = ( version = Version(0, 0, 0)
"__weakref__", tags: typing.List[str]
"version",
"auth",
"team",
"slot",
"send_index",
"tags",
"messageprocessor",
"ctx",
"remote_items",
"remote_start_inventory",
"no_items",
"no_locations",
"no_text",
)
version: Version
auth: bool
team: int | None
slot: int | None
send_index: int
tags: list[str]
messageprocessor: ClientMessageProcessor
ctx: weakref.ref[Context]
remote_items: bool remote_items: bool
remote_start_inventory: bool remote_start_inventory: bool
no_items: bool no_items: bool
@@ -167,7 +135,6 @@ class Client(Endpoint):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None: def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket) super().__init__(socket)
self.version = no_version
self.auth = False self.auth = False
self.team = None self.team = None
self.slot = None self.slot = None
@@ -175,11 +142,6 @@ class Client(Endpoint):
self.tags = [] self.tags = []
self.messageprocessor = client_message_processor(ctx, self) self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx) self.ctx = weakref.ref(ctx)
self.remote_items = False
self.remote_start_inventory = False
self.no_items = False
self.no_locations = False
self.no_text = False
@property @property
def items_handling(self): def items_handling(self):
@@ -217,7 +179,6 @@ class Context:
"release_mode": str, "release_mode": str,
"remaining_mode": str, "remaining_mode": str,
"collect_mode": str, "collect_mode": str,
"countdown_mode": str,
"item_cheat": bool, "item_cheat": bool,
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
@@ -247,8 +208,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()): log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger self.logger = logger
super(Context, self).__init__() super(Context, self).__init__()
self.slot_info = {} self.slot_info = {}
@@ -281,7 +242,6 @@ class Context:
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat self.item_cheat = item_cheat
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[ self.client_activity_timers: typing.Dict[
@@ -667,7 +627,6 @@ class Context:
"server_password": self.server_password, "password": self.password, "server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode, "release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility} "item_cheat": self.item_cheat, "compatibility": self.compatibility}
} }
@@ -702,7 +661,6 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"] self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"] self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"] self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"] self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"] self.compatibility = savedata["game_options"]["compatibility"]
@@ -1177,13 +1135,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
status: HintStatus | None = None) -> typing.List[Hint]: -> 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.
"""
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
@@ -1199,39 +1152,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
else: else:
found = location_id in ctx.location_checks[team, finding_player] found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
hint_status = status # Assign again because we're in a for loop
if found: if found:
hint_status = HintStatus.HINT_FOUND new_status = HintStatus.HINT_FOUND
elif hint_status is None: elif item_flags & ItemClassification.trap:
if item_flags & ItemClassification.trap: new_status = HintStatus.HINT_AVOID
hint_status = HintStatus.HINT_AVOID hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
else: item_flags, new_status))
hint_status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
)
return hints return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]: -> 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.
"""
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] 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, def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]: -> 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.
"""
prev_hint = ctx.get_hint(team, slot, seeked_location) prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint: if prev_hint:
return [prev_hint] return [prev_hint]
@@ -1241,16 +1180,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
found = seeked_location in ctx.location_checks[team, slot] found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status
if found: if found:
status = HintStatus.HINT_FOUND new_status = HintStatus.HINT_FOUND
elif status is None: elif item_flags & ItemClassification.trap:
if item_flags & ItemClassification.trap: new_status = HintStatus.HINT_AVOID
status = HintStatus.HINT_AVOID return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
else: new_status)]
status = HintStatus.HINT_PRIORITY
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
return [] return []
@@ -1364,8 +1300,7 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default argname += "=" + parameter.default
argtext += argname argtext += argname
argtext += " " argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n')) s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s return s
def _cmd_help(self): def _cmd_help(self):
@@ -1394,6 +1329,19 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor): class CommonCommandProcessor(CommandProcessor):
ctx: Context 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): def _cmd_options(self):
"""List all current options. Warning: lists password.""" """List all current options. Warning: lists password."""
self.output("Current options:") self.output("Current options:")
@@ -1535,23 +1483,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect") " You can ask the server admin for a /collect")
return False return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
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_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
@@ -1679,6 +1610,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot) 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: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@@ -1704,9 +1636,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location: 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: 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: else:
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
@@ -1726,18 +1658,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]: 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 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 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 elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = [] hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]: for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game): if loc_name in self.ctx.location_names_for_game(game):
hints.extend( hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
)
else: # location name 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: else:
self.output(response) self.output(response)
@@ -2015,7 +1945,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location] target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint: 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)) 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) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
if locs and create_as_hint: if locs and create_as_hint:
@@ -2030,16 +1961,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if not locations: if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments", await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}]) "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 = [] hints = []
@@ -2307,19 +2228,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect") self.output(f"Could not find player {player_name} to collect")
return False 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 @mark_raw
def _cmd_release(self, player_name: str) -> bool: def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients.""" """Send out the remaining items from a player to their intended recipients."""
@@ -2441,9 +2349,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]: 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 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 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: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
@@ -2477,14 +2385,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable: if usable:
if isinstance(location, int): 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]: elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = [] hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]: 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): 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: 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: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
else: else:
@@ -2512,11 +2423,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"): elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str): def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"): elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"} valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else []) valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2604,13 +2510,6 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion auto-enabled: !collect is available and automatically triggered on goal completion
''') ''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\ choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s) Select !remaining Accessibility. (default: %(default)s)
@@ -2676,7 +2575,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.countdown_mode, args.remaining_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network) args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata data_filename = args.multidata
@@ -2711,13 +2610,7 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve( ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
functools.partial(server, ctx=ctx),
host=ctx.host,
port=ctx.port,
ssl=ssl_context,
extensions=[server_per_message_deflate_factory],
)
ip = args.host if args.host else Utils.get_public_ipv4() ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password)) 'No password' if not ctx.password else 'Password: %s' % ctx.password))

View File

@@ -174,8 +174,6 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint: class Endpoint:
__slots__ = ("socket",)
socket: "ServerConnection" socket: "ServerConnection"
def __init__(self, socket): def __init__(self, socket):

View File

@@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict): class StartInventory(ItemDict):
"""Start with the specified amount of these items. Example: "Bomb: 1" """ """Start with these items."""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True rich_text_doc = True
@@ -1388,7 +1388,7 @@ class StartInventory(ItemDict):
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1" """Start with these items and don't place them in the world.
The game decides what the replacement items will be. The game decides what the replacement items will be.
""" """
@@ -1446,7 +1446,6 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)], Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)], Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool), Optional("link_replacement"): Or(None, bool),
Optional("skip_if_solo"): Or(None, bool),
} }
]) ])
@@ -1474,10 +1473,8 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options) super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links: if link["name"] in existing_links:
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. " raise Exception(f"You cannot have more than one link named {link['name']}.")
f"You have more than one link named '{link['name']}'.")
existing_links.add(link["name"]) existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
@@ -1755,10 +1752,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
res = template.render( res = template.render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc, cleandoc=cleandoc,
) )

View File

@@ -20,6 +20,7 @@ Currently, the following games are supported:
* Meritous * Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3) * Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder * ChecksFinder
* ArchipIDLE
* Hollow Knight * Hollow Knight
* The Witness * The Witness
* Sonic Adventure 2: Battle * Sonic Adventure 2: Battle
@@ -80,8 +81,6 @@ Currently, the following games are supported:
* Super Mario Land 2: 6 Golden Coins * Super Mario Land 2: 6 Golden Coins
* shapez * shapez
* Paint * Paint
* Celeste (Open World)
* Choo-Choo Charles
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). 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 Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -18,7 +18,7 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils import Utils
import settings from settings import Settings
from Utils import async_start from Utils import async_start
from MultiServer import mark_raw from MultiServer import mark_raw
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = settings.get_settings().sni_options.sni_path sni_path = Settings.sni_options.sni_path
if not os.path.isdir(sni_path): if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path) sni_path = Utils.local_path(sni_path)
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None: async def run_game(romfile: str) -> None:
auto_start = settings.get_settings().sni_options.snes_rom_start auto_start = Settings.sni_options.snes_rom_start
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

11
Starcraft2Client.py Normal file
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()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import concurrent.futures
import json import json
import typing import typing
import builtins import builtins
@@ -36,7 +35,7 @@ if typing.TYPE_CHECKING:
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
return Version(*(int(piece) for piece in version.split("."))) return Version(*(int(piece, 10) for piece in version.split(".")))
class Version(typing.NamedTuple): class Version(typing.NamedTuple):
@@ -323,13 +322,11 @@ def get_options() -> Settings:
return get_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() 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 = storage.setdefault(category, {})
category_dict[key] = value category_dict[key] = value
path = user_path("_persistent_storage.yaml")
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper)) f.write(dump(storage, Dumper=Dumper))
@@ -478,7 +475,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoItem, self.options_module.PlandoText)): self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -721,22 +718,13 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]: def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
"""
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
arguments with it.
:param text: The response text from `get_intended_text`.
:param command: The command to which the input text should be added. Must contain the prefix used by the command
(`!` or `/`).
:return: The command with the suggested input text appended, or None if no suggestion was found.
"""
if "did you mean " in text: if "did you mean " in text:
for question in ("Didn't find something that closely matches", for question in ("Didn't find something that closely matches",
"Too many close matches"): "Too many close matches"):
if text.startswith(question): if text.startswith(question):
name = get_text_between(text, "did you mean '", name = get_text_between(text, "did you mean '",
"'? (") "'? (")
return f"{command} {name}" return f"!{command} {name}"
elif text.startswith("Missing: "): elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ") return text.replace("Missing: ", "!hint_location ")
return None return None
@@ -952,15 +940,15 @@ class DeprecateDict(dict):
def _extend_freeze_support() -> None: def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first.""" """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# original upstream issue: https://github.com/python/cpython/issues/76327 # 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 # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing import multiprocessing
import multiprocessing.spawn import multiprocessing.spawn
def _freeze_support() -> None: def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen.""" """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 # Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None multiprocessing.process.ORIGINAL_DIR = None
@@ -987,23 +975,17 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs) multiprocessing.spawn.spawn_main(**kwargs)
sys.exit() sys.exit()
def _noop() -> None: if not is_windows and is_frozen():
pass multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
def freeze_support() -> None: 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 import multiprocessing
_extend_freeze_support()
deprecate("Use multiprocessing.freeze_support() instead")
multiprocessing.freeze_support() multiprocessing.freeze_support()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *, def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, 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: linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
@@ -1139,40 +1121,3 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str): if isinstance(obj, str):
return False return False
return isinstance(obj, typing.Iterable) return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown

View File

@@ -99,23 +99,16 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) 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.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
try: try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp() update_sprites_lttp()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.warning("Could not update LttP sprites.") logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
from worlds import AutoWorldRegister
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
create_options_files() create_options_files()
copy_tutorials_files_to_static() copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:

View File

@@ -11,53 +11,6 @@ from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData 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>") @api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]: def tracker_data(tracker: UUID) -> dict[str, Any]:
@@ -76,77 +29,120 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
all_players: dict[int, list[int]] = tracker_data.get_all_players() all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = [] class PlayerAlias(TypedDict):
player: int
name: str | None
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
"""Slot aliases of all players.""" """Slot aliases of all players."""
for team, players in all_players.items(): for team, players in all_players.items():
team_player_aliases: list[PlayerAlias] = []
team_aliases = {"team": team, "players": team_player_aliases}
player_aliases.append(team_aliases)
for player in players: for player in players:
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)}) team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = [] class PlayerItemsReceived(TypedDict):
player: int
items: list[NetworkItem]
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
"""Items received by each player.""" """Items received by each player."""
for team, players in all_players.items(): for team, players in all_players.items():
player_received_items: list[PlayerItemsReceived] = []
team_items_received = {"team": team, "players": player_received_items}
player_items_received.append(team_items_received)
for player in players: for player in players:
player_items_received.append( player_received_items.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)}) {"player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = [] class PlayerChecksDone(TypedDict):
player: int
locations: list[int]
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
"""ID of all locations checked by each player.""" """ID of all locations checked by each player."""
for team, players in all_players.items(): for team, players in all_players.items():
per_player_checks: list[PlayerChecksDone] = []
team_checks_done = {"team": team, "players": per_player_checks}
player_checks_done.append(team_checks_done)
for player in players: for player in players:
player_checks_done.append( per_player_checks.append(
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) {"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [ total_checks_done: list[dict[str, int]] = [
{"team": team, "checks_done": checks_done} {"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items() for team, checks_done in tracker_data.get_team_locations_checked_count().items()
] ]
"""Total number of locations checked for the entire multiworld per team.""" """Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = [] class PlayerHints(TypedDict):
player: int
hints: list[Hint]
hints: list[dict[str, int | list[PlayerHints]]] = []
"""Hints that all players have used or received.""" """Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items(): for team, players in tracker_data.get_all_slots().items():
per_player_hints: list[PlayerHints] = []
team_hints = {"team": team, "players": per_player_hints}
hints.append(team_hints)
for player in players: for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player)) player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints}) per_player_hints.append({"player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player) slot_info = tracker_data.get_slot_info(team, player)
# this assumes groups are always after players # this assumes groups are always after players
if slot_info.type != SlotType.group: if slot_info.type != SlotType.group:
continue continue
for member in slot_info.group_members: for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints team_hints[member]["hints"] += player_hints
activity_timers: list[PlayerTimer] = [] class PlayerTimer(TypedDict):
player: int
time: datetime | None
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made.""" """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 team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_timers = {"team": team, "players": player_timers}
activity_timers.append(team_timers)
for player in players: for player in players:
activity_timers.append({"team": team, "player": player, "time": None}) player_timers.append({"player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []): client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ())
for entry in activity_timers: for (team, player), timestamp in client_activity_timers:
if entry["team"] == team and entry["player"] == player: # use index since we can rely on order
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc) activity_timers[team]["player_timers"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
connection_timers: list[PlayerTimer] = [] connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made.""" """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 team, players in all_players.items():
player_timers: list[PlayerTimer] = []
team_connection_timers = {"team": team, "players": player_timers}
connection_timers.append(team_connection_timers)
for player in players: for player in players:
connection_timers.append({"team": team, "player": player, "time": None}) player_timers.append({"player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []): client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get(
# find the matching entry "client_connection_timers", ())
for entry in connection_timers: for (team, player), timestamp in client_connection_timers:
if entry["team"] == team and entry["player"] == player: connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
player_status: list[PlayerStatus] = [] class PlayerStatus(TypedDict):
player: int
status: ClientStatus
player_status: list[dict[str, int | list[PlayerStatus]]] = []
"""The current client status for each player.""" """The current client status for each player."""
for team, players in all_players.items(): for team, players in all_players.items():
player_statuses: list[PlayerStatus] = []
team_status = {"team": team, "players": player_statuses}
player_status.append(team_status)
for player in players: for player in players:
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)}) player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
return { return {
**get_static_tracker_data(room),
"aliases": player_aliases, "aliases": player_aliases,
"player_items_received": player_items_received, "player_items_received": player_items_received,
"player_checks_done": player_checks_done, "player_checks_done": player_checks_done,
@@ -155,87 +151,80 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"activity_timers": activity_timers, "activity_timers": activity_timers,
"connection_timers": connection_timers, "connection_timers": connection_timers,
"player_status": player_status, "player_status": player_status,
"datapackage": tracker_data._multidata["datapackage"],
} }
@cache.memoize()
class PlayerGroups(TypedDict): def get_static_tracker_data(room: Room) -> dict[str, Any]:
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>. Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
: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) tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players() all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = [] class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
groups: list[dict[str, int | list[PlayerGroups]]] = []
"""The Slot ID of groups and the IDs of the group's members.""" """The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items(): for team, players in tracker_data.get_all_slots().items():
groups_in_team: list[PlayerGroups] = []
team_groups = {"team": team, "groups": groups_in_team}
groups.append(team_groups)
for player in players: for player in players:
slot_info = tracker_data.get_slot_info(player) slot_info = tracker_data.get_slot_info(team, player)
if slot_info.type != SlotType.group or not slot_info.group_members: if slot_info.type != SlotType.group or not slot_info.group_members:
continue continue
groups.append( groups_in_team.append(
{ {
"slot": player, "slot": player,
"name": slot_info.name, "name": slot_info.name,
"members": list(slot_info.group_members), "members": list(slot_info.group_members),
}) })
break class PlayerName(TypedDict):
player: int
name: str
player_locations_total: list[PlayerLocationsTotal] = [] player_names: list[dict[str, str | list[PlayerName]]] = []
"""Slot names of all players."""
for team, players in all_players.items(): for team, players in all_players.items():
per_team_player_names: list[PlayerName] = []
team_names = {"team": team, "players": per_team_player_names}
player_names.append(team_names)
for player in players: for player in players:
player_locations_total.append( per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
class PlayerGame(TypedDict):
player: int
game: str
games: list[dict[str, int | list[PlayerGame]]] = []
"""The game each player is playing."""
for team, players in all_players.items():
player_games: list[PlayerGame] = []
team_games = {"team": team, "players": player_games}
games.append(team_games)
for player in players:
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
"""Slot data for each player."""
for team, players in all_players.items():
player_slot_data: list[PlayerSlotData] = []
team_slot_data = {"team": team, "players": player_slot_data}
slot_data.append(team_slot_data)
for player in players:
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
return { return {
"groups": groups, "groups": groups,
"datapackage": tracker_data._multidata["datapackage"], "slot_data": slot_data,
"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

View File

@@ -36,39 +36,25 @@ def handle_generation_failure(result: BaseException):
logging.exception(e) logging.exception(e)
def _mp_gen_game( def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
gen_options: dict,
meta: dict[str, Any] | None = None,
owner=None,
sid=None,
timeout: int|None = None,
) -> PrimaryKey | None:
from setproctitle import setproctitle from setproctitle import setproctitle
setproctitle(f"Generator ({sid})") setproctitle(f"Generator ({sid})")
try: res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout) setproctitle(f"Generator (idle)")
finally: return res
setproctitle(f"Generator (idle)")
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None: def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try: try:
meta = json.loads(generation.meta) meta = json.loads(generation.meta)
options = restricted_loads(generation.options) options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players") logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async( pool.apply_async(_mp_gen_game, (options,),
_mp_gen_game, {"meta": meta,
(options,), "sid": generation.id,
{ "owner": generation.owner},
"meta": meta, handle_generation_success, handle_generation_failure)
"sid": generation.id,
"owner": generation.owner,
"timeout": timeout,
},
handle_generation_success,
handle_generation_failure,
)
except Exception as e: except Exception as e:
generation.state = STATE_ERROR generation.state = STATE_ERROR
commit() commit()
@@ -149,7 +135,6 @@ def autogen(config: dict):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool: initargs=(config,), maxtasksperchild=10) as generator_pool:
job_time = config["JOB_TIME"]
with db_session: with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -160,7 +145,7 @@ def autogen(config: dict):
if sid: if sid:
generation.delete() generation.delete()
else: else:
launch_generator(generator_pool, generation, timeout=job_time) launch_generator(generator_pool, generation)
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
@@ -172,7 +157,7 @@ def autogen(config: dict):
generation for generation in Generation generation for generation in Generation
if generation.state == STATE_QUEUED).for_update() if generation.state == STATE_QUEUED).for_update()
for generation in to_start: for generation in to_start:
launch_generator(generator_pool, generation, timeout=job_time) launch_generator(generator_pool, generation)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")

View File

@@ -19,10 +19,7 @@ from pony.orm import commit, db_session, select
import Utils import Utils
from MultiServer import ( from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless from Utils import restricted_loads, cache_argsless
from .locker import Locker from .locker import Locker
from .models import Command, GameDataPackage, Room, db from .models import Command, GameDataPackage, Room, db
@@ -100,7 +97,6 @@ class WebHostContext(Context):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete() command.delete()
commit() commit()
del commands
time.sleep(5) time.sleep(5)
@db_session @db_session
@@ -150,13 +146,13 @@ class WebHostContext(Context):
self.location_name_groups = static_location_name_groups self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True) return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True): def init_save(self, enabled: bool = True):
self.saving = enabled self.saving = enabled
if self.saving: if self.saving:
with db_session: savegame_data = Room.get(id=self.room_id).multisave
savegame_data = Room.get(id=self.room_id).multisave if savegame_data:
if savegame_data: self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False) self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start() threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@@ -286,12 +282,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None assert ctx.server is None
try: try:
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server await ctx.server
except OSError: # likely port in use except OSError: # likely port in use
ctx.server = websockets.serve( ctx.server = websockets.serve(
@@ -312,7 +304,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session: with db_session:
room = Room.get(id=ctx.room_id) room = Room.get(id=ctx.room_id)
room.last_port = port room.last_port = port
del room
else: else:
ctx.logger.exception("Could not determine port. Likely hosting failure.") ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session: with db_session:
@@ -331,7 +322,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_port = -1 room.last_port = -1
del room
logger.exception(e) logger.exception(e)
raise raise
else: else:
@@ -343,12 +333,11 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task # NOTE: async saving should probably be an async task and could be merged with shutdown_task
with db_session: with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer # ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \ room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout) datetime.timedelta(minutes=1, seconds=room.timeout)
del room
logging.info(f"Shutting down room {room_id} on {name}.") logging.info(f"Shutting down room {room_id} on {name}.")
finally: finally:
await asyncio.sleep(5) await asyncio.sleep(5)

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 pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits 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 Main import main as ERmain
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor from Utils import __version__, restricted_dumps
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -33,7 +34,6 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)), "server_password": str(options_source.get("server_password", None)),
} }
@@ -73,10 +73,6 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def format_exception(e: BaseException) -> str:
return f"{e.__class__.__name__}: {e}"
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"])) results, gen_options = roll_options(options, set(meta["plando_options"]))
@@ -97,9 +93,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
except PicklingError as e: except PicklingError as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
meta["error"] = format_exception(e) return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
commit() commit()
@@ -107,18 +101,16 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
else: else:
try: try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"]) meta=meta, owner=session["_id"].int)
except BaseException as e: except BaseException as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
meta["error"] = format_exception(e) return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return redirect(url_for("view_seed", seed=seed_id)) return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None): def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
if meta is None: if meta is None:
meta = {} meta = {}
@@ -137,47 +129,43 @@ 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)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse() erargs = parse_arguments(['--multi', str(playercount)])
args.multi = playercount erargs.seed = seed
args.seed = seed erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.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)
args.spoiler = meta["generator_options"].get("spoiler", 0) erargs.race = race
args.race = race erargs.outputname = seedname
args.outputname = seedname erargs.outputpath = target.name
args.outputpath = target.name erargs.teams = 1
args.teams = 1 erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", {"bosses", "items", "connections", "texts"}))
{"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False
args.skip_prog_balancing = False erargs.skip_output = False
args.skip_output = False erargs.spoiler_only = False
args.spoiler_only = False erargs.csv_output = 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)
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items(): for k, v in settings.items():
if v is not None: if v is not None:
if hasattr(args, k): if hasattr(erargs, k):
getattr(args, k)[player] = v getattr(erargs, k)[player] = v
else: else:
setattr(args, k, {player: v}) setattr(erargs, k, {player: v})
if not args.name[player]: if not erargs.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0] erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name): if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task) thread = thread_pool.submit(task)
try: try:
return thread.result(timeout) return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e: except concurrent.futures.TimeoutError as e:
if sid: if sid:
with db_session: with db_session:
@@ -185,14 +173,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None: if gen is not None:
gen.state = STATE_ERROR gen.state = STATE_ERROR
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = ("Allowed time for Generation exceeded, " + meta["error"] = (
"please consider generating locally instead. " + "Allowed time for Generation exceeded, please consider generating locally instead. " +
format_exception(e)) e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -200,15 +185,10 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None: if gen is not None:
gen.state = STATE_ERROR gen.state = STATE_ERROR
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = format_exception(e) meta["error"] = (e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>') @app.route('/wait/<suuid:seed>')
@@ -222,9 +202,7 @@ def wait_seed(seed: UUID):
if not generation: if not generation:
return "Generation not found." return "Generation not found."
elif generation.state == STATE_ERROR: elif generation.state == STATE_ERROR:
meta = json.loads(generation.meta) return render_template("seedError.html", seed_error=generation.meta)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("waitSeed.html", seed_id=seed_id) return render_template("waitSeed.html", seed_id=seed_id)

View File

@@ -3,10 +3,10 @@ import threading
import json import json
from Utils import local_path, user_path from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp(): def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk from tkinter import Tk
from LttPAdjuster import get_image_for_sprite from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress from LttPAdjuster import BackgroundTaskProgress

View File

@@ -260,10 +260,7 @@ def host_room(room: UUID):
# indicate that the page should reload to get the assigned port # 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)) 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)) or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
with db_session:
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"
room.last_activity = now # will trigger a spinup, if it's not already running room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari" browser_tokens = "Mozilla", "Chrome", "Safari"
@@ -271,9 +268,9 @@ def host_room(room: UUID):
or "Discordbot" in request.user_agent.string or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens)) or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]: def get_log(max_size: int = 0 if automated else 1024000) -> str:
if max_size == 0: if max_size == 0:
return "", 0 return ""
try: try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0 raw_size = 0
@@ -284,9 +281,9 @@ def host_room(room: UUID):
break break
raw_size += len(block) raw_size += len(block)
fragments.append(block.decode("utf-8")) fragments.append(block.decode("utf-8"))
return "".join(fragments), raw_size return "".join(fragments)
except FileNotFoundError: except FileNotFoundError:
return "", 0 return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log) return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)

View File

@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines() lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:])) text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer='html', settings=None, settings_overrides={ return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False, 'raw_enable': False,
'file_insertion_enabled': False, 'file_insertion_enabled': False,
'output_encoding': 'unicode' 'output_encoding': 'unicode'
@@ -155,9 +155,7 @@ def generate_weighted_yaml(game: str):
options = {} options = {}
for key, val in request.form.items(): for key, val in request.form.items():
if val == "_ensure-empty-list": if "||" not in key:
options[key] = {}
elif "||" not in key:
if len(str(val)) == 0: if len(str(val)) == 0:
continue continue
@@ -214,11 +212,8 @@ def generate_yaml(game: str):
if request.method == "POST": if request.method == "POST":
options = {} options = {}
intent_generate = False intent_generate = False
for key, val in request.form.items(multi=True): for key, val in request.form.items(multi=True):
if val == "_ensure-empty-list": if key in options:
options[key] = []
elif options.get(key):
if not isinstance(options[key], list): if not isinstance(options[key], list):
options[key] = [options[key]] options[key] = [options[key]]
options[key].append(val) options[key].append(val)
@@ -231,7 +226,7 @@ def generate_yaml(game: str):
if key_parts[-1] == "qty": if key_parts[-1] == "qty":
if key_parts[0] not in options: if key_parts[0] not in options:
options[key_parts[0]] = {} options[key_parts[0]] = {}
if val and val != "0": if val != "0":
options[key_parts[0]][key_parts[1]] = int(val) options[key_parts[0]][key_parts[1]] = int(val)
del options[key] del options[key]

View File

@@ -1,11 +1,9 @@
flask>=3.1.1 flask>=3.1.1
werkzeug>=3.1.3 werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12' pony>=0.7.19
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2 waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17; python_version >= '3.12' Flask-Compress>=1.17
Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2

View File

@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game. comfortable exploiting certain glitches in the game.
## I want to develop a game implementation for Archipelago. How do I do that? ## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub: The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
@@ -77,5 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev** If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
channel on our Discord.

View File

@@ -1,43 +1,49 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Reload tracker every 60 seconds (sync'd) // Reload tracker every 15 seconds
const url = window.location; const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js setInterval(() => {
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3; const ajax = new XMLHttpRequest();
console.log("Target second of refresh: " + targetSecond); ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
let getSleepTimeSeconds = () => { // Create a fake DOM using the returned HTML
// -40 % 60 is -40, which is absolutely wrong and should burn const domParser = new DOMParser();
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60; const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
return sleepSeconds || 60;
};
let updateTracker = () => { // Update item tracker
const ajax = new XMLHttpRequest(); document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
ajax.onreadystatechange = () => { // Update only counters in the location-table
if (ajax.readyState !== 4) { return; } let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
// Create a fake DOM using the returned HTML for (let i = 0; i < counters.length; i++) {
const domParser = new DOMParser(); counters[i].innerHTML = fakeCounters[i].innerHTML;
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);
}; };
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;
});
}
}); });

View File

@@ -1,279 +1,160 @@
*{ #player-tracker-wrapper{
margin: 0; margin: 0;
font-family: "JuraBook", monospace;
}
body{
--icon-size: 36px;
--item-class-padding: 4px;
}
a{
color: #1ae;
} }
/* Section colours */ #tracker-table td {
#player-info{ vertical-align: top;
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;
} }
/* Sections */ .inventory-table-area{
.section-body{ border: 2px solid #000000;
display: flex; border-radius: 4px;
flex-flow: row wrap; padding: 3px 10px 3px 10px;
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;
} }
/* Acquire item filters */ .inventory-table-area:has(.inventory-table-terran) {
.tracker-section img{ width: 690px;
height: 100%; background-color: #525494;
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);
} }
/* Item groups */ .inventory-table-area:has(.inventory-table-zerg) {
.item-class{ width: 360px;
display: flex; background-color: #9d60d2;
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;
} }
/* Subsections */ .inventory-table-area:has(.inventory-table-protoss) {
.section-toc{ width: 400px;
display: flex; background-color: #d2b260;
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;
} }
/* Progressive items */ #tracker-table .inventory-table td{
.progressive{ width: 40px;
max-height: var(--icon-size); height: 40px;
display: contents; text-align: center;
vertical-align: middle;
} }
.lvl-0 > :nth-child(2), .inventory-table td.title{
.lvl-0 > :nth-child(3), padding-top: 10px;
.lvl-0 > :nth-child(4), height: 20px;
.lvl-0 > :nth-child(5){ font-family: "JuraBook", monospace;
display: none; font-size: 16px;
} font-weight: bold;
.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;
} }
/* Filler item counters */ .inventory-table img{
.item-counter{ height: 100%;
display: table; max-width: 40px;
text-align: center; max-height: 40px;
padding: var(--item-class-padding); border: 1px solid #000000;
} filter: grayscale(100%) contrast(75%) brightness(20%);
.item-count{ background-color: black;
display: table-cell;
vertical-align: middle;
padding-left: 3px;
padding-right: 15px;
} }
/* Hidden items */ .inventory-table img.acquired{
.hidden-class:not(:has(img.acquired)){ filter: none;
display: none; background-color: black;
}
.hidden-item:not(.acquired){
display:none;
} }
/* Keys */ .inventory-table .tint-terran img.acquired {
#keys ol, #keys ul{ filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
#keys li{
padding-right: 15pt;
} }
/* Locations */ .inventory-table .tint-protoss img.acquired {
#section-locations{ filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
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: "✔ ";
} }
/* Allowing scrolling down a little further */ .inventory-table .tint-level-1 img.acquired {
.bottom-padding{ filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
min-height: 33vh; }
}
.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

View File

@@ -72,13 +72,3 @@ code{
padding-right: 0.25rem; padding-right: 0.25rem;
color: #000000; color: #000000;
} }
code.grassy {
background-color: #b5e9a4;
border: 1px solid #2a6c2f;
white-space: preserve;
text-align: left;
display: block;
font-size: 14px;
line-height: 20px;
}

View File

@@ -13,7 +13,3 @@
min-height: 360px; min-height: 360px;
text-align: center; text-align: center;
} }
h2, h4 {
color: #ffffff;
}

View File

@@ -98,7 +98,7 @@
<td> <td>
{% if hint.finding_player == player %} {% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b> <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> <i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %} {% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}"> <a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
@@ -109,7 +109,7 @@
<td> <td>
{% if hint.receiving_player == player %} {% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b> <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> <i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %} {% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}"> <a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">

View File

@@ -58,7 +58,8 @@
Open Log File... Open Log File...
</a> </a>
</div> </div>
{% set log, log_len = get_log() -%} {% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div> <div id="logger" style="white-space: pre">{{ log }}</div>
<script> <script>
let url = '{{ url_for('display_log', room = room.id) }}'; let url = '{{ url_for('display_log', room = room.id) }}';

View File

@@ -45,15 +45,15 @@
{%- set current_sphere = loop.index %} {%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %} {%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %} {%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(player) %} {%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(player) %} {%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %} {%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr> <tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %} {%- 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>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(player) }}</td> <td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(receiver) }}</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.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td> <td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td> <td>{{ finder_game }}</td>

View File

@@ -22,14 +22,14 @@
-%} -%}
<tr> <tr>
<td> <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> <i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %} {% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }} {{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %} {% endif %}
</td> </td>
<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> <i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %} {% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }} {{ player_names_with_alias[(team, hint.receiving_player)] }}

View File

@@ -134,7 +134,6 @@
{% macro OptionList(option_name, option) %} {% macro OptionList(option_name, option) %}
{{ OptionTitle(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"> <div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry"> <div class="option-entry">
@@ -147,7 +146,6 @@
{% macro LocationSet(option_name, option) %} {% macro LocationSet(option_name, option) %}
{{ OptionTitle(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"> <div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %} {% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %} {% if group_name != "Everywhere" %}
@@ -171,7 +169,6 @@
{% macro ItemSet(option_name, option) %} {% macro ItemSet(option_name, option) %}
{{ OptionTitle(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"> <div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %} {% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %} {% if group_name != "Everything" %}
@@ -195,7 +192,6 @@
{% macro OptionSet(option_name, option) %} {% macro OptionSet(option_name, option) %}
{{ OptionTitle(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"> <div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry"> <div class="option-entry">

View File

@@ -4,20 +4,16 @@
{% block head %} {% block head %}
<title>Generation failed, please retry.</title> <title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% include 'header/oceanIslandHeader.html' %} {% include 'header/oceanIslandHeader.html' %}
<div id="wait-seed-wrapper" class="grass-island"> <div id="wait-seed-wrapper" class="grass-island">
<div id="wait-seed"> <div id="wait-seed">
<h1>Generation Failed</h1> <h1>Generation failed</h1>
<h2>Please try again!</h2> <h2>please retry</h2>
<p>{{ seed_error }}</p> {{ seed_error }}
<h4>More details:</h4>
<p>
<code class="grassy">{{ details }}</code>
</p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -31,9 +31,6 @@
{% include 'header/oceanHeader.html' %} {% include 'header/oceanHeader.html' %}
<div id="games" class="markdown"> <div id="games" class="markdown">
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
<div class="js-only"> <div class="js-only">
<label for="game-search">Search for your game below!</label><br /> <label for="game-search">Search for your game below!</label><br />
<div class="page-controls"> <div class="page-controls">

File diff suppressed because it is too large Load Diff

View File

@@ -139,7 +139,6 @@
{% endmacro %} {% endmacro %}
{% macro OptionList(option_name, option) %} {% macro OptionList(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="list-container"> <div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry"> <div class="list-entry">
@@ -159,7 +158,6 @@
{% endmacro %} {% endmacro %}
{% macro LocationSet(option_name, option, world) %} {% 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"> <div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %} {% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %} {% if group_name != "Everywhere" %}
@@ -182,7 +180,6 @@
{% endmacro %} {% endmacro %}
{% macro ItemSet(option_name, option, world) %} {% 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"> <div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %} {% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %} {% if group_name != "Everything" %}
@@ -205,7 +202,6 @@
{% endmacro %} {% endmacro %}
{% macro OptionSet(option_name, option) %} {% macro OptionSet(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container"> <div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry"> <div class="set-entry">

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,6 @@ from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom from worlds.tloz import Items, Locations, Rom
from settings import get_settings
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua" 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 # Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("ZeldaClient") 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: async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str], 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: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -220,8 +220,6 @@
<MessageBoxLabel>: <MessageBoxLabel>:
theme_text_color: "Custom" theme_text_color: "Custom"
text_color: 1, 1, 1, 1 text_color: 1, 1, 1, 1
<MessageBox>:
height: self.content.texture_size[1] + 80
<ScrollBox>: <ScrollBox>:
layout: layout layout: layout
bar_width: "12dp" bar_width: "12dp"
@@ -235,3 +233,8 @@
spacing: 10 spacing: 10
size_hint_y: None size_hint_y: None
height: self.minimum_height height: self.minimum_height
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -33,10 +33,6 @@ description: {{ yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }} game: {{ yaml_dump(game) }}
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. 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) %} {%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values. # You can define additional values between the minimum and maximum values.

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
data/sprites/remote/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -21,6 +21,9 @@
# Aquaria # Aquaria
/worlds/aquaria/ @tioui /worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
# Blasphemous # Blasphemous
/worlds/blasphemous/ @TRPG0 /worlds/blasphemous/ @TRPG0
@@ -39,15 +42,9 @@
# Celeste 64 # Celeste 64
/worlds/celeste64/ @PoryGone /worlds/celeste64/ @PoryGone
# Celeste (Open World)
/worlds/celeste_open_world/ @PoryGone
# ChecksFinder # ChecksFinder
/worlds/checksfinder/ @SunCatMC /worlds/checksfinder/ @SunCatMC
# Choo-Choo Charles
/worlds/cccharles/ @Yaranorgoth
# Civilization VI # Civilization VI
/worlds/civ6/ @hesto2 /worlds/civ6/ @hesto2
@@ -72,9 +69,6 @@
# Faxanadu # Faxanadu
/worlds/faxanadu/ @Daivuk /worlds/faxanadu/ @Daivuk
# Final Fantasy (1)
/worlds/ff1/ @Rosalie-A
# Final Fantasy Mystic Quest # Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0 /worlds/ffmq/ @Alchav @wildham0
@@ -244,6 +238,9 @@
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for # 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. # any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
# /worlds/ff1/
# Ocarina of Time # Ocarina of Time
# /worlds/oot/ # /worlds/oot/

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 * 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. 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 ## World
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the

View File

@@ -1,83 +1,26 @@
# APWorld Specification # apworld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation. Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
These are called "APWorlds". Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details. See [world api.md](world%20api.md) for details.
APWorlds can either be a folder, or they can be packaged as an .apworld file.
## .apworld File Format apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution **Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
by placing a `*.apworld` file into the worlds folder.
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
## File Format
apworld files are zip archives, all lower case, with the file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
**Warning:** `.apworld` files have to be all lower case,
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
## Metadata ## Metadata
Metadata about the APWorld is defined in an `archipelago.json` file. No metadata is specified yet.
If the APWorld is a folder, the only required field is "game":
```json
{
"game": "Game Name"
}
```
There are also the following optional fields:
* `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
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
package managers. Should always be a list of strings.
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this:
```json
{
"game": "Game Name",
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"]
}
```
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
```json
{
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"],
"version": 7,
"compatible_version": 7,
"game": "Game Name"
}
```
This is the recommended workflow for packaging your world to an `.apworld`.
## Extra Data ## Extra Data
@@ -86,7 +29,7 @@ The zip can contain arbitrary files in addition what was specified above.
## Caveats ## Caveats
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions` Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World` `from worlds.AutoWorld import World`

View File

@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
Terrain matching or dungeon shuffle: Terrain matching or dungeon shuffle:
```python ```python
def randomize_within_same_group(group: int) -> list[int]: def randomize_within_same_group(group: int) -> List[int]:
return [group] return [group]
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group) identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
``` ```
Directional + area shuffle: Directional + area shuffle:
```python ```python
def get_target_groups(group: int) -> list[int]: def get_target_groups(group: int) -> List[int]:
# example group: LEFT | CAVE # example group: LEFT | CAVE
# example result: [RIGHT | CAVE, DOOR | CAVE] # example result: [RIGHT | CAVE, DOOR | CAVE]
direction = group & Groups.DIRECTION_MASK direction = group & Groups.DIRECTION_MASK

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. | | 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` | | 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. | | 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. | | 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. | | 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. | | 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. An object representing static information about a slot.
```python ```python
from collections.abc import Sequence import typing
from typing import NamedTuple
from NetUtils import SlotType from NetUtils import SlotType
class NetworkSlot(NamedTuple): class NetworkSlot(typing.NamedTuple):
name: str name: str
game: str game: str
type: SlotType type: SlotType
group_members: Sequence[int] = [] # only populated if type == group group_members: typing.List[int] = [] # only populated if type == group
``` ```
### Permission ### Permission
@@ -687,8 +686,8 @@ class Permission(enum.IntEnum):
### Hint ### Hint
An object representing a Hint. An object representing a Hint.
```python ```python
from typing import NamedTuple import typing
class Hint(NamedTuple): class Hint(typing.NamedTuple):
receiving_player: int receiving_player: int
finding_player: int finding_player: int
location: int location: int

View File

@@ -10,7 +10,7 @@ What you'll need:
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version * [Python 3.11.9 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 * On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available. 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 * pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler * Matching C compiler
* possibly optional, read operating system specific sections * possibly optional, read operating system specific sections

View File

@@ -28,7 +28,7 @@ if it does not exist.
## Global Settings ## Global Settings
All non-world-specific settings are defined directly in settings.py. 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 To access a "global" config value, with correct typing, use one of
```python ```python

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.

View File

@@ -15,10 +15,8 @@
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation, * Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
use single quotes inside them: `f"Like {dct['key']}"` use single quotes inside them: `f"Like {dct['key']}"`
* Use type annotations where possible for function signatures and class members. * 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 * 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. 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]]`).
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the * 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. beginning of a line at the same indentation as the beginning of the line with the open bracket.
```python ```python
@@ -62,9 +60,3 @@
* Indent `case` inside `switch ` with 2 spaces. * Indent `case` inside `switch ` with 2 spaces.
* Use single quotes. * Use single quotes.
* Semicolons are required after every statement. * 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.

View File

@@ -82,10 +82,10 @@ overridden. For more information on what methods are available to your class, ch
#### Alternatives to WorldTestBase #### Alternatives to WorldTestBase
Unit tests can also be created using 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) directly. These may be useful [unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
for generating a multiworld under very specific constraints without using the generic world setup, or for testing may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
portions of your code that can be tested without relying on a multiworld to be created first. testing portions of your code that can be tested without relying on a multiworld to be created first.
#### Parametrization #### 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 * 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 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 #### Performance Considerations

View File

@@ -18,8 +18,6 @@ Current endpoints:
- [`/room_status/<suuid:room_id>`](#roomstatus) - [`/room_status/<suuid:room_id>`](#roomstatus)
- Tracker API - Tracker API
- [`/tracker/<suuid:tracker>`](#tracker) - [`/tracker/<suuid:tracker>`](#tracker)
- [`/static_tracker/<suuid:tracker>`](#statictracker)
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
- User API - User API
- [`/get_rooms`](#getrooms) - [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds) - [`/get_seeds`](#getseeds)
@@ -256,6 +254,8 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
<a name=tracker></a> <a name=tracker></a>
Will provide a dict of tracker data with the following keys: Will provide a dict of tracker data with the following keys:
- item_link groups and their players (`groups`)
- Each player's slot_data (`slot_data`)
- Each player's current alias (`aliases`) - Each player's current alias (`aliases`)
- Will return the name if there is none - 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 items each player has received as a NetworkItem (`player_items_received`)
@@ -265,55 +265,111 @@ Will provide a dict of tracker data with the following keys:
- The time of last activity of each player in RFC 1123 format (`activity_timers`) - 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 time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`) - The current client status of each player (`player_status`)
- The datapackage hash for each player (`datapackage`)
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
Example: Example:
```json ```json
{ {
"groups": [
{
"team": 0,
"groups": [
{
"slot": 5,
"name": "testGroup",
"members": [
1,
2
]
},
{
"slot": 6,
"name": "myCoolLink",
"members": [
3,
4
]
}
]
}
],
"slot_data": [
{
"team": 0,
"players": [
{
"player": 1,
"slot_data": {
"example_option": 1,
"other_option": 3
}
},
{
"player": 2,
"slot_data": {
"example_option": 1,
"other_option": 2
}
}
]
}
],
"aliases": [ "aliases": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"alias": "Incompetence" {
}, "player": 1,
{ "alias": "Incompetence"
"team": 0, },
"player": 2, {
"alias": "Slot_Name_2" "player": 2,
"alias": "Slot_Name_2"
}
]
} }
], ],
"player_items_received": [ "player_items_received": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"items": [ {
[1, 1, 1, 0], "player": 1,
[2, 2, 2, 1] "items": [
] [1, 1, 1, 0],
}, [2, 2, 2, 1]
{ ]
"team": 0, },
"player": 2, {
"items": [ "player": 2,
[1, 1, 1, 2], "items": [
[2, 2, 2, 0] [1, 1, 1, 2],
[2, 2, 2, 0]
]
}
] ]
} }
], ],
"player_checks_done": [ "player_checks_done": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"locations": [ {
1, "player": 1,
2 "locations": [
] 1,
}, 2
{ ]
"team": 0, },
"player": 2, {
"locations": [ "player": 2,
1, "locations": [
2 1,
2
]
}
] ]
} }
], ],
@@ -326,132 +382,76 @@ Example:
"hints": [ "hints": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"hints": [ {
[1, 2, 4, 6, 0, "", 4, 0] "player": 1,
"hints": [
[1, 2, 4, 6, 0, "", 4, 0]
]
},
{
"player": 2,
"hints": []
}
] ]
},
{
"team": 0,
"player": 2,
"hints": []
} }
], ],
"activity_timers": [ "activity_timers": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"time": "Fri, 18 Apr 2025 20:35:45 GMT" {
}, "player": 1,
{ "time": "Fri, 18 Apr 2025 20:35:45 GMT"
"team": 0, },
"player": 2, {
"time": "Fri, 18 Apr 2025 20:42:46 GMT" "player": 2,
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
}
]
} }
], ],
"connection_timers": [ "connection_timers": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"time": "Fri, 18 Apr 2025 20:38:25 GMT" {
}, "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": 2,
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
}
]
} }
], ],
"player_status": [ "player_status": [
{ {
"team": 0, "team": 0,
"player": 1, "players": [
"status": 0 {
}, "player": 1,
{ "status": 0
"team": 0, },
"player": 2, {
"status": 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": { "datapackage": {
"Archipelago": { "Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb", "checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
"version": 0
}, },
"The Messenger": { "The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b", "checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
} "version": 0
},
"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
@@ -554,4 +554,4 @@ Example:
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb" "seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
} }
] ]
``` ```

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 * `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'. 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 * `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 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 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. select from on the game's options page.
@@ -753,7 +753,7 @@ from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import LogicMixin from worlds.AutoWorld import LogicMixin
class MyGameState(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: def init_mixin(self, multiworld: MultiWorld) -> None:
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set. # Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
@@ -882,11 +882,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. that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
```python ```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 # 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. # 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. # 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. # option's value.
return self.options.as_dict("difficulty", "final_boss_hp") return self.options.as_dict("difficulty", "final_boss_hp")
``` ```

View File

@@ -74,12 +74,13 @@ class EntranceLookup:
if entrance in self._expands_graph_cache: if entrance in self._expands_graph_cache:
return self._expands_graph_cache[entrance] return self._expands_graph_cache[entrance]
seen = {entrance.connected_region} visited = set()
q: deque[Region] = deque() q: deque[Region] = deque()
q.append(entrance.connected_region) q.append(entrance.connected_region)
while q: while q:
region = q.popleft() region = q.popleft()
visited.add(region)
# check if the region itself is progression # check if the region itself is progression
if region in region.multiworld.indirect_connections: if region in region.multiworld.indirect_connections:
@@ -102,8 +103,7 @@ class EntranceLookup:
and exit_ in self._usable_exits): and exit_ in self._usable_exits):
self._expands_graph_cache[entrance] = True self._expands_graph_cache[entrance] = True
return True return True
elif exit_.connected_region and exit_.connected_region not in seen: elif exit_.connected_region and exit_.connected_region not in visited:
seen.add(exit_.connected_region)
q.append(exit_.connected_region) q.append(exit_.connected_region)
self._expands_graph_cache[entrance] = False self._expands_graph_cache[entrance] = False

View File

@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";

14
kvui.py
View File

@@ -720,11 +720,13 @@ class MessageBoxLabel(MDLabel):
class MessageBox(Popup): class MessageBox(Popup):
def __init__(self, title, text, error=False, **kwargs): 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.] 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), super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs) separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class MDNavigationItemBase(MDNavigationItem): class MDNavigationItemBase(MDNavigationItem):
@@ -838,15 +840,15 @@ class GameManager(ThemedApp):
self.log_panels: typing.Dict[str, Widget] = {} self.log_panels: typing.Dict[str, Widget] = {}
# keep track of last used command to autofill on click # keep track of last used command to autofill on click
self.last_autofillable_command = "!hint" self.last_autofillable_command = "hint"
autofillable_commands = ("!hint_location", "!hint", "!getitem") autofillable_commands = ("hint_location", "hint", "getitem")
original_say = ctx.on_user_say original_say = ctx.on_user_say
def intercept_say(text): def intercept_say(text):
text = original_say(text) text = original_say(text)
if text: if text:
for command in autofillable_commands: for command in autofillable_commands:
if text.startswith(command): if text.startswith("!" + command):
self.last_autofillable_command = command self.last_autofillable_command = command
break break
return text return text
@@ -1099,6 +1101,10 @@ class GameManager(ThemedApp):
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []) hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.hint_log.refresh_hints(hints) self.hint_log.refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
def open_settings(self, *largs):
pass
class LogtoUI(logging.Handler): class LogtoUI(logging.Handler):
def __init__(self, on_log): def __init__(self, on_log):

View File

@@ -579,17 +579,6 @@ class ServerOptions(Group):
"goal" -> Client can ask for remaining items after goal completion "goal" -> Client can ask for remaining items after goal completion
""" """
class CountdownMode(str):
"""
Countdown modes
Determines whether or not a player can initiate a countdown with !countdown
Note that /countdown is always available to the host.
"enabled" -> Client can always initiate a countdown with !countdown.
"disabled" -> Client can never initiate a countdown with !countdown.
"auto" -> !countdown will be available for any room with less than 30 slots.
"""
class AutoShutdown(int): class AutoShutdown(int):
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running""" """Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
@@ -624,7 +613,6 @@ class ServerOptions(Group):
release_mode: ReleaseMode = ReleaseMode("auto") release_mode: ReleaseMode = ReleaseMode("auto")
collect_mode: CollectMode = CollectMode("auto") collect_mode: CollectMode = CollectMode("auto")
remaining_mode: RemainingMode = RemainingMode("goal") remaining_mode: RemainingMode = RemainingMode("goal")
countdown_mode: CountdownMode = CountdownMode("auto")
auto_shutdown: AutoShutdown = AutoShutdown(0) auto_shutdown: AutoShutdown = AutoShutdown(0)
compatibility: Compatibility = Compatibility(2) compatibility: Compatibility = Compatibility(2)
log_network: LogNetwork = LogNetwork(0) log_network: LogNetwork = LogNetwork(0)

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 # 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: try:
import pkg_resources import pkg_resources
try: try:
@@ -65,6 +65,7 @@ from Cython.Build import cythonize
non_apworlds: set[str] = { non_apworlds: set[str] = {
"A Link to the Past", "A Link to the Past",
"Adventure", "Adventure",
"ArchipIDLE",
"Archipelago", "Archipelago",
"Lufia II Ancient Cave", "Lufia II Ancient Cave",
"Meritous", "Meritous",
@@ -371,7 +372,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True) os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
from Options import generate_yaml_templates from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import APWorldContainer
assert not non_apworlds - set(AutoWorldRegister.world_types), \ assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = [] folders_to_remove: list[str] = []
@@ -380,36 +380,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
if worldname not in non_apworlds: if worldname not in non_apworlds:
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"):
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
manifest = {}
# this method creates an apworld that cannot be moved to a different OS or minor python version, # this method creates an apworld that cannot be moved to a different OS or minor python version,
# which should be ok # which should be ok
zip_path = self.libfolder / "worlds" / (file_name + ".apworld") with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
apworld = APWorldContainer(str(zip_path))
apworld.minimum_ap_version = version_tuple
apworld.maximum_ap_version = version_tuple
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,
compresslevel=9) as zf: compresslevel=9) as zf:
for path in world_directory.rglob("*.*"): for path in world_directory.rglob("*.*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:]) 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.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest))
folders_to_remove.append(file_name) folders_to_remove.append(file_name)
shutil.rmtree(world_directory) shutil.rmtree(world_directory)
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml") shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")

3
test/TestBase.py Normal file
View File

@@ -0,0 +1,3 @@
from .bases import TestBase, WorldTestBase
from warnings import warn
warn("TestBase was renamed to bases", DeprecationWarning)

View File

@@ -9,7 +9,98 @@ from test.general import gen_steps
from worlds import AutoWorld from worlds import AutoWorld
from worlds.AutoWorld import World, call_all 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): class WorldTestBase(unittest.TestCase):

View File

@@ -1,227 +0,0 @@
#!/usr/bin/env python
# based on python-websockets compression benchmark (c) Aymeric Augustin and contributors
# https://github.com/python-websockets/websockets/blob/main/experiments/compression/benchmark.py
import collections
import time
import zlib
from typing import Iterable
REPEAT = 10
WB, ML = 12, 5 # defaults used as a reference
WBITS = range(9, 16)
MEMLEVELS = range(1, 10)
def benchmark(data: Iterable[bytes]) -> None:
size: dict[int, dict[int, float]] = collections.defaultdict(dict)
duration: dict[int, dict[int, float]] = collections.defaultdict(dict)
for wbits in WBITS:
for memLevel in MEMLEVELS:
encoder = zlib.compressobj(wbits=-wbits, memLevel=memLevel)
encoded = []
print(f"Compressing {REPEAT} times with {wbits=} and {memLevel=}")
t0 = time.perf_counter()
for _ in range(REPEAT):
for item in data:
# Taken from PerMessageDeflate.encode
item = encoder.compress(item) + encoder.flush(zlib.Z_SYNC_FLUSH)
if item.endswith(b"\x00\x00\xff\xff"):
item = item[:-4]
encoded.append(item)
t1 = time.perf_counter()
size[wbits][memLevel] = sum(len(item) for item in encoded) / REPEAT
duration[wbits][memLevel] = (t1 - t0) / REPEAT
raw_size = sum(len(item) for item in data)
print("=" * 79)
print("Compression ratio")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (1 - size[wbits][memLevel] / raw_size):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print("CPU time")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{1000 * duration[wbits][memLevel]:.1f}ms"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Size vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (size[wbits][memLevel] / size[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Time vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (duration[wbits][memLevel] / duration[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
def generate_data_package_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +4.6% size, -5.0% time .. +1.1% time
# 10, 4 saves 20K RAM, gives +10.2% size, -3.8% time .. +0.6% time
# 11, 3 saves 20K RAM, gives +6.5% size, +14.2% time
# 10, 3 saves 24K RAM, gives +12.8% size, +0.5% time .. +6.9% time
# NOTE: time delta is highly unstable; time is ~100ms
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from NetUtils import encode
from worlds import network_data_package
return [encode(network_data_package).encode("utf-8")]
def generate_solo_release_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +0.9% size, +3.9% time
# 10, 4 saves 20K RAM, gives +1.4% size, +3.4% time
# 11, 3 saves 20K RAM, gives +1.8% size, +13.9% time
# 10, 3 saves 24K RAM, gives +2.1% size, +4.8% time
# NOTE: time delta is highly unstable; time is ~0.4ms
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
solo_release = []
solo_release_locations = [r.randint(1000, 1999) for _ in range(200)]
solo_release_items = sorted([r.randint(1000, 1999) for _ in range(200)]) # currently sorted by item
solo_player = 1
for location, item in zip(solo_release_locations, solo_release_items):
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
network_item = NetworkItem(item, location, solo_player, flags)
solo_release.append(json_format_send_event(network_item, solo_player))
solo_release.append({
"cmd": "ReceivedItems",
"index": 0,
"items": solo_release_items,
})
solo_release.append({
"cmd": "RoomUpdate",
"hint_points": 200,
"checked_locations": solo_release_locations,
})
return [encode(solo_release).encode("utf-8")]
def generate_gameplay_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +13.6% size, +4.1% time
# 10, 4 saves 20K RAM, gives +22.3% size, +2.2% time
# 10, 3 saves 24K RAM, gives +26.2% size, +1.6% time
# NOTE: time delta is highly unstable; time is 4ms
from copy import copy
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
gameplay = []
observer = 1
hint_points = 0
index = 0
players = list(range(1, 10))
player_locations = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_items = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_receiver = {player: [r.randint(1, len(players)) for _ in range(200)] for player in players}
for i in range(0, len(player_locations[1])):
player_sequence = copy(players)
r.shuffle(player_sequence)
for finder in player_sequence:
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
receiver = player_receiver[finder][i]
item = player_items[finder][i]
location = player_locations[finder][i]
network_item = NetworkItem(item, location, receiver, flags)
gameplay.append(json_format_send_event(network_item, observer))
if finder == observer:
hint_points += 1
gameplay.append({
"cmd": "RoomUpdate",
"hint_points": hint_points,
"checked_locations": [location],
})
if receiver == observer:
gameplay.append({
"cmd": "ReceivedItems",
"index": index,
"items": [item],
})
index += 1
return [encode(gameplay).encode("utf-8")]
def main() -> None:
#corpus = generate_data_package_corpus()
#corpus = generate_solo_release_corpus()
#corpus = generate_gameplay_corpus()
corpus = generate_data_package_corpus() + generate_solo_release_corpus() + generate_gameplay_corpus()
benchmark(corpus)
print(f"raw size: {sum(len(data) for data in corpus)}")
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,4 @@
def run_locations_benchmark(freeze_gc: bool = True) -> None: def run_locations_benchmark():
"""
Run a benchmark of location access rule performance against an empty_state and an all_state.
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
than running all iterations for the location rule being benchmarked.
"""
import argparse import argparse
import logging import logging
import gc import gc
@@ -42,8 +34,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
if freeze_gc:
gc.freeze()
with TimeIt(f"{test_location.game} {self.rule_iterations} " with TimeIt(f"{test_location.game} {self.rule_iterations} "
f"runs of {test_location}.access_rule({state_name})", logger) as t: f"runs of {test_location}.access_rule({state_name})", logger) as t:
for _ in range(self.rule_iterations): for _ in range(self.rule_iterations):
@@ -51,8 +41,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
# if time is taken to disentangle complex ref chains, # if time is taken to disentangle complex ref chains,
# this time should be attributed to the rule. # this time should be attributed to the rule.
gc.collect() gc.collect()
if freeze_gc:
gc.unfreeze()
return t.dif return t.dif
def main(self): def main(self):
@@ -76,13 +64,9 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
gc.collect() gc.collect()
for step in self.gen_steps: for step in self.gen_steps:
if freeze_gc:
gc.freeze()
with TimeIt(f"{game} step {step}", logger): with TimeIt(f"{game} step {step}", logger):
call_all(multiworld, step) call_all(multiworld, step)
gc.collect() gc.collect()
if freeze_gc:
gc.unfreeze()
locations = sorted(multiworld.get_unfilled_locations()) locations = sorted(multiworld.get_unfilled_locations())
if not locations: if not locations:

View File

@@ -1,5 +1,5 @@
from argparse import Namespace 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 BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package from worlds import network_data_package
@@ -31,8 +31,8 @@ def setup_solo_multiworld(
return setup_multiworld(world_type, steps, seed) return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str, ...] = gen_steps, def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: int | None = None, options: dict[str, Any] | list[dict[str, Any]] = None) -> MultiWorld: seed: Optional[int] = None) -> MultiWorld:
""" """
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps. 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 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 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 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 :return: The generated multiworld
""" """
if not isinstance(worlds, list): if not isinstance(worlds, list):
worlds = [worlds] worlds = [worlds]
if options is None:
options = [{}] * len(worlds)
elif not isinstance(options, list):
options = [options] * len(worlds)
players = len(worlds) players = len(worlds)
multiworld = MultiWorld(players) multiworld = MultiWorld(players)
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)} 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.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed) multiworld.set_seed(seed)
args = Namespace() 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(): for key, option in world_type.options_dataclass.type_hints.items():
updated_options = getattr(args, key, {}) 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) setattr(args, key, updated_options)
multiworld.set_options(args) multiworld.set_options(args)
multiworld.state = CollectionState(multiworld) multiworld.state = CollectionState(multiworld)

View File

@@ -6,9 +6,9 @@ from Utils import get_intended_text, get_input_text_from_response
class TestClient(unittest.TestCase): class TestClient(unittest.TestCase):
def test_autofill_hint_from_fuzzy_hint(self) -> None: def test_autofill_hint_from_fuzzy_hint(self) -> None:
tests = ( tests = (
("item", ["item1", "item2"]), # Multiple close matches ("item", ["item1", "item2"]), # Multiple close matches
("itm", ["item1", "item21"]), # No close match, multiple option ("itm", ["item1", "item21"]), # No close match, multiple option
("item", ["item1"]), # No close match, single option ("item", ["item1"]), # No close match, single option
("item", ["\"item\" 'item' (item)"]), # Testing different special characters ("item", ["\"item\" 'item' (item)"]), # Testing different special characters
) )
@@ -16,7 +16,7 @@ class TestClient(unittest.TestCase):
item_name, usable, response = get_intended_text(input_text, possible_answers) item_name, usable, response = get_intended_text(input_text, possible_answers)
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed") self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
hint_command = get_input_text_from_response(response, "!hint") hint_command = get_input_text_from_response(response, "hint")
self.assertIsNotNone(hint_command, self.assertIsNotNone(hint_command,
"The response to fuzzy hints is no longer recognized by the hint autofill") "The response to fuzzy hints is no longer recognized by the hint autofill")
self.assertEqual(hint_command, f"!hint {item_name}", self.assertEqual(hint_command, f"!hint {item_name}",

View File

@@ -37,11 +37,10 @@ class TestImplemented(unittest.TestCase):
def test_slot_data(self): def test_slot_data(self):
"""Tests that if a world creates slot data, it's json serializable.""" """Tests that if a world creates slot data, it's json serializable."""
# has an await for generate_output which isn't being called for game_name, world_type in AutoWorldRegister.world_types.items():
excluded_games = ("Ocarina of Time",) # has an await for generate_output which isn't being called
worlds_to_test = {game: world if game_name in {"Ocarina of Time"}:
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games} continue
for game_name, world_type in worlds_to_test.items():
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed): with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld) distribute_items_restrictive(multiworld)

View File

@@ -150,7 +150,8 @@ class TestBase(unittest.TestCase):
"""Test that worlds don't modify the locality of items after duplicates are resolved""" """Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",) gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill") 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): with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps) multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy() local_items = multiworld.worlds[1].options.local_items.value.copy()

View File

@@ -33,10 +33,7 @@ class TestBase(unittest.TestCase):
def test_location_creation_steps(self): def test_location_creation_steps(self):
"""Tests that Regions and Locations aren't created after `create_items`.""" """Tests that Regions and Locations aren't created after `create_items`."""
gen_steps = ("generate_early", "create_regions", "create_items") gen_steps = ("generate_early", "create_regions", "create_items")
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue") for game_name, world_type in AutoWorldRegister.world_types.items():
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():
with self.subTest("Game", game_name=game_name): with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps) multiworld = setup_solo_multiworld(world_type, gen_steps)
region_count = len(multiworld.get_regions()) region_count = len(multiworld.get_regions())
@@ -57,13 +54,13 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "generate_basic") call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()), self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic") 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") f"{game_name} modified locations count during generate_basic")
call_all(multiworld, "pre_fill") call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()), self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill") 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") f"{game_name} modified locations count during pre_fill")
def test_location_group(self): def test_location_group(self):

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from BaseClasses import PlandoOptions from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts from Options import ItemLinks, Choice
from Utils import restricted_dumps from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
for link in item_links.values(): for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0]) self.assertEqual(link.value[0], item_link_group[0])
def test_pickle_dumps_default(self): def test_pickle_dumps(self):
"""Test that default option values can be pickled into database for WebHost generation""" """Test options can be pickled into database for WebHost generation"""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden: if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
@@ -81,23 +81,3 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default)) restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup: if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default])) restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
class TestPlandoConnections(PlandoConnections):
entrances = {"An Entrance"}
exits = {"An Exit"}
plando_connection_value = PlandoConnections(
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
)
plando_values = {
"PlandoConnections": plando_connection_value,
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
}
for option_key, value in plando_values.items():
with self.subTest(option=option_key):
restricted_dumps(value)

View File

@@ -1,14 +0,0 @@
import unittest
from Utils import DaemonThreadPoolExecutor
class DaemonThreadPoolExecutorTest(unittest.TestCase):
def test_is_daemon(self) -> None:
def run() -> None:
pass
with DaemonThreadPoolExecutor(1) as executor:
executor.submit(run)
self.assertTrue(next(iter(executor._threads)).daemon)

View File

@@ -93,13 +93,3 @@ class TestTracker(TestBase):
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
) )
self.assertEqual(response.status_code, 400) 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)

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 Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState from BaseClasses import CollectionState
from Utils import Version from Utils import deprecate
if TYPE_CHECKING: if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
@@ -22,10 +22,6 @@ if TYPE_CHECKING:
perf_logger = logging.getLogger("performance") perf_logger = logging.getLogger("performance")
class InvalidItemError(KeyError):
pass
class AutoWorldRegister(type): class AutoWorldRegister(type):
world_types: Dict[str, Type[World]] = {} world_types: Dict[str, Type[World]] = {}
__file__: str __file__: str
@@ -75,10 +71,6 @@ class AutoWorldRegister(type):
if "required_client_version" in base.__dict__: if "required_client_version" in base.__dict__:
dct["required_client_version"] = max(dct["required_client_version"], dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["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 # construct class
new_class = super().__new__(mcs, name, bases, dct) 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.""" """If loaded from a .apworld, this is the Path to it."""
__file__: ClassVar[str] __file__: ClassVar[str]
"""path it was loaded from""" """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): def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None assert multiworld is not None

View File

@@ -8,8 +8,7 @@ import os
import threading import threading
from io import BytesIO from io import BytesIO
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence, from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
TYPE_CHECKING)
import bsdiff4 import bsdiff4
@@ -17,9 +16,6 @@ semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading del threading
if TYPE_CHECKING:
from Utils import Version
class AutoPatchRegister(abc.ABCMeta): class AutoPatchRegister(abc.ABCMeta):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {} patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -69,7 +65,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
return handler return handler
container_version: int = 7 container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int): def is_ap_player_container(game: str, data: bytes, player: int):
@@ -96,7 +92,7 @@ class APContainer:
version: ClassVar[int] = container_version version: ClassVar[int] = container_version
compression_level: ClassVar[int] = 9 compression_level: ClassVar[int] = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
manifest_path: str = "archipelago.json"
path: Optional[str] path: Optional[str]
def __init__(self, path: Optional[str] = None): def __init__(self, path: Optional[str] = None):
@@ -120,7 +116,7 @@ class APContainer:
except Exception as e: except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e raise Exception(f"Manifest {manifest} did not convert to json.") from e
else: 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: 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.""" """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 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]: def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
try: with opened_zipfile.open("archipelago.json", "r") as f:
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:
manifest = json.load(f) manifest = json.load(f)
if manifest["compatible_version"] > self.version: if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new " 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
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, 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): class APPlayerContainer(APContainer):
"""A zipfile containing at least archipelago.json meant for a player""" """A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None game: ClassVar[Optional[str]] = None
@@ -290,8 +248,10 @@ class APProcedurePatch(APAutoPatchInterface):
manifest["compatible_version"] = 5 manifest["compatible_version"] = 5
return manifest return manifest
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]: def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile) 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: if "procedure" not in manifest:
# support patching files made before moving to procedures # support patching files made before moving to procedures
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])] self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
@@ -300,7 +260,6 @@ class APProcedurePatch(APAutoPatchInterface):
for file in opened_zipfile.namelist(): for file in opened_zipfile.namelist():
if file not in ["archipelago.json"]: if file not in ["archipelago.json"]:
self.files[file] = opened_zipfile.read(file) self.files[file] = opened_zipfile.read(file)
return manifest
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None: def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
super(APProcedurePatch, self).write_contents(opened_zipfile) super(APProcedurePatch, self).write_contents(opened_zipfile)

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple 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): class Type(Enum):
@@ -177,10 +177,11 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
if module_name == loaded_name: if module_name == loaded_name:
found_already_loaded = True found_already_loaded = True
break break
if found_already_loaded and is_kivy_running(): if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is 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.") "so a Launcher restart is required to use the new installation.\n"
world_source = worlds.WorldSource(str(target), is_zip=True, relative=False) "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) bisect.insort(worlds.world_sources, world_source)
world_source.load() world_source.load()
@@ -196,7 +197,7 @@ def install_apworld(apworld_path: str = "") -> None:
source, target = res source, target = res
except Exception as e: except Exception as e:
import Utils import Utils
Utils.messagebox("Notice", str(e), error=True) Utils.messagebox(e.__class__.__name__, str(e), error=True)
logging.exception(e) logging.exception(e)
else: else:
import Utils import Utils
@@ -217,6 +218,8 @@ components: List[Component] = [
description="Install an APWorld to play games not included with Archipelago by default."), description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."), description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time # Ocarina of Time
Component('OoT Client', 'OoTClient', Component('OoT Client', 'OoTClient',
@@ -226,6 +229,8 @@ components: List[Component] = [
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')), Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion # Zillion
Component('Zillion Client', 'ZillionClient', Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')), file_identifier=SuffixIdentifier('.apzl')),
@@ -240,67 +245,3 @@ icon_paths = {
'icon': local_path('data', 'icon.png'), 'icon': local_path('data', 'icon.png'),
'discord': local_path('data', 'discord-mark-blue.png'), 'discord': local_path('data', 'discord-mark-blue.png'),
} }
if not is_frozen():
def _build_apworlds(*launch_args: str):
import json
import os
import zipfile
from worlds import AutoWorldRegister
from worlds.Files import APWorldContainer
from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
args = parser.parse_args(launch_args)
if args.worlds:
games = [(game, AutoWorldRegister.world_types.get(game, None)) for game in args.worlds]
else:
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
if not worldtype.zip_path]
apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in games:
if not worldtype:
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
continue
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")):
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
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))
open_folder(apworlds_folder)
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -7,11 +7,10 @@ import warnings
import zipimport import zipimport
import time import time
import dataclasses import dataclasses
import json
from typing import List from typing import List
from NetUtils import DataPackage 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__) local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds") 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 is_zip: bool = False
relative: bool = True # relative to regular world import folder relative: bool = True # relative to regular world import folder
time_taken: float = -1.0 time_taken: float = -1.0
version: Version = Version(0, 0, 0)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" 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 # import all submodules to trigger AutoWorldRegister
world_sources.sort() world_sources.sort()
apworlds: list[WorldSource] = []
for world_source in world_sources: for world_source in world_sources:
# load all loose files first: world_source.load()
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"):
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
break
if manifest:
break
game = manifest.get("game")
if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_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
# Build the data package for each game. # Build the data package for each game.
from .AutoWorld import AutoWorldRegister
network_data_package: DataPackage = { network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()}, "games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
} }

View File

@@ -19,7 +19,7 @@ class GameData:
""" """
:param data: :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.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} 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 # 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 self._proto = proto
# What happens if we comment this out? Should this not be commented out? What is its purpose? # 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: def __repr__(self) -> str:
return f"AbilityData(name={self._proto.button_name})" return f"AbilityData(name={self._proto.button_name})"

View File

@@ -623,23 +623,6 @@ class ParadeTrapWeight(Range):
default = 20 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 @dataclass
class AHITOptions(PerGameCommonOptions): class AHITOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool start_inventory_from_pool: StartInventoryPool
@@ -717,8 +700,6 @@ class AHITOptions(PerGameCommonOptions):
ParadeTrapWeight: ParadeTrapWeight ParadeTrapWeight: ParadeTrapWeight
death_link: DeathLink death_link: DeathLink
death_link_amnesty: DeathLinkAmnesty
dw_death_link_amnesty: DWDeathLinkAmnesty
ahit_option_groups: Dict[str, List[Any]] = { ahit_option_groups: Dict[str, List[Any]] = {
@@ -788,6 +769,4 @@ slot_data_options: List[str] = [
"MaxPonCost", "MaxPonCost",
"death_link", "death_link",
"death_link_amnesty",
"dw_death_link_amnesty",
] ]

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

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

View File

@@ -5,7 +5,7 @@ from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import item_factory from worlds.alttp.Items import item_factory
from worlds.alttp.Regions import create_regions from worlds.alttp.Regions import create_regions
from worlds.alttp.Shops import create_shops from worlds.alttp.Shops import create_shops
from worlds.alttp.test.bases import LTTPTestBase from worlds.alttp.test import LTTPTestBase
class TestDungeon(LTTPTestBase): class TestDungeon(LTTPTestBase):

View File

@@ -1,12 +1,13 @@
from ...Dungeons import get_dungeon_item_pool from worlds.alttp.Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances from worlds.alttp.EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions from worlds.alttp.InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ...Items import item_factory from worlds.alttp.Items import item_factory
from ...Regions import mark_light_world_regions from worlds.alttp.Regions import mark_light_world_regions
from ...Shops import create_shops 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): class TestInverted(TestBase, LTTPTestBase):

View File

@@ -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.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Rules import set_inverted_big_bomb_rules 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): class TestInvertedBombRules(LTTPTestBase):

View File

@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool from worlds.alttp.Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances from worlds.alttp.EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions from worlds.alttp.InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ...Items import item_factory from worlds.alttp.Items import item_factory
from ...Options import GlitchesRequired from worlds.alttp.Options import GlitchesRequired
from ...Regions import mark_light_world_regions from worlds.alttp.Regions import mark_light_world_regions
from ...Shops import create_shops 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): class TestInvertedMinor(TestBase, LTTPTestBase):

View File

@@ -1,13 +1,14 @@
from ...Dungeons import get_dungeon_item_pool from worlds.alttp.Dungeons import get_dungeon_item_pool
from ...EntranceShuffle import link_inverted_entrances from worlds.alttp.EntranceShuffle import link_inverted_entrances
from ...InvertedRegions import create_inverted_regions from worlds.alttp.InvertedRegions import create_inverted_regions
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ...Items import item_factory from worlds.alttp.Items import item_factory
from ...Options import GlitchesRequired from worlds.alttp.Options import GlitchesRequired
from ...Regions import mark_light_world_regions from worlds.alttp.Regions import mark_light_world_regions
from ...Shops import create_shops 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): class TestInvertedOWG(TestBase, LTTPTestBase):

View File

@@ -1,5 +1,5 @@
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ..bases import TestBase from test.bases import TestBase
base_items = 41 base_items = 41
extra_counts = (15, 15, 10, 5, 25) extra_counts = (15, 15, 10, 5, 25)

View File

@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool from worlds.alttp.Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions from worlds.alttp.InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ...Items import item_factory from worlds.alttp.Items import item_factory
from ...Options import GlitchesRequired 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): class TestMinor(TestBase, LTTPTestBase):

View File

@@ -1,10 +1,11 @@
from ...Dungeons import get_dungeon_item_pool from worlds.alttp.Dungeons import get_dungeon_item_pool
from ...InvertedRegions import mark_dark_world_regions from worlds.alttp.InvertedRegions import mark_dark_world_regions
from ...ItemPool import difficulties from worlds.alttp.ItemPool import difficulties
from ...Items import item_factory from worlds.alttp.Items import item_factory
from ...Options import GlitchesRequired 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): class TestVanillaOWG(TestBase, LTTPTestBase):

View File

@@ -1,5 +1,5 @@
from ...Shops import shop_table from worlds.alttp.Shops import shop_table
from ..bases import TestBase from test.bases import TestBase
class TestSram(TestBase): class TestSram(TestBase):

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