Compare commits

..

2 Commits

Author SHA1 Message Date
Exempt-Medic
86a6939f02 Also fix max count 2025-06-12 13:05:00 -04:00
Exempt-Medic
51254948aa Fix plando count value 2025-06-12 12:57:32 -04:00
620 changed files with 28229 additions and 119278 deletions

View File

@@ -1,210 +0,0 @@
.git
.github
.run
docs
test
typings
*Client.py
.idea
.vscode
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
*multidata
*multisave
*.archipelago
*.apsave
*.BIN
*.puml
setups
build
bundle/components.wxs
dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml
/config.yaml
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
*-errors.txt
success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# vim editor
*.swp
# SageMath parsed files
*.sage.py
# Environments
.env
.venv*
env/
venv/
/venv*/
ENV/
env.bak/
venv.bak/
*.code-workspace
shell.nix
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

View File

@@ -29,7 +29,7 @@
"reportMissingImports": true, "reportMissingImports": true,
"reportMissingTypeStubs": true, "reportMissingTypeStubs": true,
"pythonVersion": "3.11", "pythonVersion": "3.10",
"pythonPlatform": "Windows", "pythonPlatform": "Windows",
"executionEnvironments": [ "executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
if: env.diff != '' if: env.diff != ''
with: with:
python-version: '3.11' python-version: '3.10'
- name: "Install dependencies" - name: "Install dependencies"
if: env.diff != '' if: env.diff != ''

View File

@@ -9,25 +9,17 @@ 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:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -106,7 +98,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -142,13 +134,10 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
@@ -200,7 +189,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

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,17 +5,11 @@ 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
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, APPIMAGETOOL_VERSION: 13
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -128,13 +122,10 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |

View File

@@ -8,24 +8,18 @@ on:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
pull_request: pull_request:
paths: paths:
- '**' - '**'
- '!docs/**' - '!docs/**'
- '!deploy/**'
- '!setup.py' - '!setup.py'
- '!Dockerfile'
- '!*.iss' - '!*.iss'
- '!.gitignore' - '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**' - '!.github/workflows/**'
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
@@ -39,15 +33,15 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
python: python:
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10 - {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'} - {version: '3.12'}
- {version: '3.13'}
include: include:
- python: {version: '3.11'} # old compat - python: {version: '3.10'} # 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:
@@ -59,7 +53,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r ci-requirements.txt pip install pytest pytest-subtests pytest-xdist
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests - name: Unittests
@@ -75,7 +69,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

7
.gitignore vendored
View File

@@ -56,6 +56,7 @@ success.txt
output/ output/
Output Logs/ Output Logs/
/factorio/ /factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated /WebHostLib/static/generated
/freeze_requirements.txt /freeze_requirements.txt
/Archipelago.zip /Archipelago.zip
@@ -183,6 +184,12 @@ _speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv # pyenv
.python-version .python-version

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

@@ -11,7 +11,6 @@ from typing import List
import Utils import Utils
from settings import get_settings
from NetUtils import ClientStatus from NetUtils import ClientStatus
from Utils import async_start from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = get_settings().adventure_options options = Utils.get_settings()
self.display_msgs = options.display_msgs self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
self.locations_array = None self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False): if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True self.set_deathlink = True
async_start(self.get_freeincarnates_used()) async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo": elif cmd == "RoomInfo":
@@ -407,7 +406,6 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS ctx.atari_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
except CancelledError: except CancelledError:
pass pass
@@ -417,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
options = get_settings().adventure_options auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
auto_start = options.rom_start rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
rom_args = options.rom_args
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -5,13 +5,12 @@ import functools
import logging import logging
import random import random
import secrets import secrets
import warnings
from argparse import Namespace from argparse import Namespace
from collections import Counter, deque, defaultdict from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
import dataclasses import dataclasses
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -154,11 +153,17 @@ class MultiWorld():
self.algorithm = 'balanced' self.algorithm = 'balanced'
self.groups = {} self.groups = {}
self.regions = self.RegionManager(players) self.regions = self.RegionManager(players)
self.shops = []
self.itempool = [] self.itempool = []
self.seed = None self.seed = None
self.seed_name: str = "Unavailable" self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids} self.precollected_items = {player: [] for player in self.player_ids}
self.required_locations = [] self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.save_and_quit_from_boss = True
self.custom = False self.custom = False
self.customitemarray = [] self.customitemarray = []
self.shuffle_ganon = True self.shuffle_ganon = True
@@ -177,7 +182,7 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.worlds = {} self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the " self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)", True) "world's random object instead (usually self.random)")
self.plando_options = PlandoOptions.none self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]: def get_all_ids(self) -> Tuple[int, ...]:
@@ -222,8 +227,17 @@ class MultiWorld():
self.seed_name = name if name else str(self.seed) self.seed_name = name if name else str(self.seed)
def set_options(self, args: Namespace) -> None: def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.", True)
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids: for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
@@ -261,7 +275,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 +298,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"]
@@ -427,27 +438,12 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location: def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name] return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False, def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState: collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
""" cached = getattr(self, "_all_state", None)
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those if use_cache and cached:
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items return cached.copy()
it is able to reach, building as complete of a completed game state as possible.
:param use_cache: Deprecated and unused.
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
sweeping, such as before entrance randomization is complete.
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
state.
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
items it can.
:return: The completed CollectionState.
"""
if __debug__ and use_cache is not None:
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
DeprecationWarning)
ret = CollectionState(self, allow_partial_entrances) ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool: for item in self.itempool:
@@ -460,6 +456,8 @@ class MultiWorld():
if perform_sweep: if perform_sweep:
ret.sweep_for_advancements() ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
return ret return ret
def get_items(self) -> List[Item]: def get_items(self) -> List[Item]:
@@ -573,9 +571,26 @@ class MultiWorld():
if self.has_beaten_game(state): if self.has_beaten_game(state):
return True return True
for _ in state.sweep_for_advancements(locations, base_locations = self.get_locations() if locations is None else locations
yield_each_sweep=True, prog_locations = {location for location in base_locations if location.item
checked_locations=state.locations_checked): and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
state.collect(location.item, True, location)
prog_locations -= sphere
if self.has_beaten_game(state): if self.has_beaten_game(state):
return True return True
@@ -691,12 +706,6 @@ class MultiWorld():
sphere.append(locations.pop(n)) sphere.append(locations.pop(n))
if not sphere: if not sphere:
if __debug__:
from Fill import FillError
raise FillError(
f"Could not access required locations for accessibility check. Missing: {locations}",
multiworld=self,
)
# ran out of places and did not finish yet, quit # ran out of places and did not finish yet, quit
logging.warning(f"Could not access required locations for accessibility check." logging.warning(f"Could not access required locations for accessibility check."
f" Missing: {locations}") f" Missing: {locations}")
@@ -860,133 +869,20 @@ class CollectionState():
"Please switch over to sweep_for_advancements.") "Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations) return self.sweep_for_advancements(locations)
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]], def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
yield_each_sweep: bool) -> Iterator[None]:
"""
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
of a yield statement.
"""
all_players = {player for player, _ in advancements_per_player}
players_to_check = all_players
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
# sweep is finished.
checking_if_finished = False
while players_to_check:
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
next_players_to_check = set()
for player, locations in advancements_per_player:
if player not in players_to_check:
next_advancements_per_player.append((player, locations))
continue
# Accessibility of each location is checked first because a player's region accessibility cache becomes
# stale whenever one of their own items is collected into the state.
reachable_locations: List[Location] = []
unreachable_locations: List[Location] = []
for location in locations:
if location.can_reach(self):
# Locations containing items that do not belong to `player` could be collected immediately
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
# items at reachable locations are collected in a single loop.
reachable_locations.append(location)
else:
unreachable_locations.append(location)
if unreachable_locations:
next_advancements_per_player.append((player, unreachable_locations))
# A previous player's locations processed in the current `while players_to_check` iteration could have
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
# their items is collected.
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
# more performant to instead discard `player` from `next_players_to_check` once their locations have
# been processed.
next_players_to_check.discard(player)
# Collect the items from the reachable locations.
for advancement in reachable_locations:
self.advancements.add(advancement)
item = advancement.item
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
if self.collect(item, True, advancement):
# The player the item belongs to may be able to reach additional locations in the next sweep
# iteration.
next_players_to_check.add(item.player)
if not next_players_to_check:
if not checking_if_finished:
# It is assumed that each player's world only logically depends on itself, which may not be the
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
checking_if_finished = True
next_players_to_check = all_players
else:
checking_if_finished = False
players_to_check = next_players_to_check
advancements_per_player = next_advancements_per_player
if yield_each_sweep:
yield
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
yield_each_sweep: Literal[True],
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
yield_each_sweep: Literal[False] = False,
checked_locations: Optional[Set[Location]] = None) -> None: ...
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
"""
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
until there are no more reachable locations that contain uncollected advancement items.
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
self.advancements when None.
"""
if checked_locations is None:
checked_locations = self.advancements
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
advancements_per_player: List[Tuple[int, List[Location]]]
if locations is None: if locations is None:
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out. locations = self.multiworld.get_filled_locations()
advancements_per_player = [] reachable_advancements = True
for player, locations_dict in self.multiworld.regions.location_cache.items(): # since the loop has a good chance to run more than once, only filter the advancements once
filtered_locations = [location for location in locations_dict.values() locations = {location for location in locations if location.advancement and location not in self.advancements}
if location.advancement and location not in checked_locations]
if filtered_locations:
advancements_per_player.append((player, filtered_locations))
else:
# Filter and separate the locations into a list for each player.
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
for location in locations:
if location.advancement and location not in checked_locations:
advancements_per_player_dict[location.player].append(location)
# Convert to a list of tuples.
advancements_per_player = list(advancements_per_player_dict.items())
del advancements_per_player_dict
if yield_each_sweep: while reachable_advancements:
# Return a generator that will yield at the end of each sweep iteration. reachable_advancements = {location for location in locations if location.can_reach(self)}
return self._sweep_for_advancements_impl(advancements_per_player, True) locations -= reachable_advancements
else: for advancement in reachable_advancements:
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations self.advancements.add(advancement)
# once started, then start and exhaust the generator by attempting to iterate it. assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
for _ in self._sweep_for_advancements_impl(advancements_per_player, False): self.collect(advancement.item, True, advancement)
assert False, "Generator yielded when it should have run to completion without yielding"
return None
# item name related # item name related
def has(self, item: str, player: int, count: int = 1) -> bool: def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -1254,13 +1150,13 @@ class Region:
self.region_manager = region_manager self.region_manager = region_manager
def __getitem__(self, index: int) -> Location: def __getitem__(self, index: int) -> Location:
return self._list[index] return self._list.__getitem__(index)
def __setitem__(self, index: int, value: Location) -> None: def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError() raise NotImplementedError()
def __len__(self) -> int: def __len__(self) -> int:
return len(self._list) return self._list.__len__()
def __iter__(self): def __iter__(self):
return iter(self._list) return iter(self._list)
@@ -1274,8 +1170,8 @@ class Region:
class LocationRegister(Register): class LocationRegister(Register):
def __delitem__(self, index: int) -> None: def __delitem__(self, index: int) -> None:
location: Location = self._list[index] location: Location = self._list.__getitem__(index)
del self._list[index] self._list.__delitem__(index)
del(self.region_manager.location_cache[location.player][location.name]) del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None: def insert(self, index: int, value: Location) -> None:
@@ -1286,8 +1182,8 @@ class Region:
class EntranceRegister(Register): class EntranceRegister(Register):
def __delitem__(self, index: int) -> None: def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list[index] entrance: Entrance = self._list.__getitem__(index)
del self._list[index] self._list.__delitem__(index)
del(self.region_manager.entrance_cache[entrance.player][entrance.name]) del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None: def insert(self, index: int, value: Entrance) -> None:
@@ -1346,7 +1242,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,16 +1331,16 @@ 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.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
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(
@@ -1533,47 +1430,31 @@ class Location:
class ItemClassification(IntFlag): class ItemClassification(IntFlag):
filler = 0b00000 filler = 0b0000
""" aka trash, as in filler items like ammo, currency etc """ """ aka trash, as in filler items like ammo, currency etc """
progression = 0b00001 progression = 0b0001
""" Item that is logically relevant. """ Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """ Protects this item from being placed on excluded or unreachable locations. """
useful = 0b00010 useful = 0b0010
""" Item that is especially useful. """ Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations. Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """ When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b00100 trap = 0b0100
""" Item that is detrimental in some way. """ """ Item that is detrimental in some way. """
skip_balancing = 0b01000 skip_balancing = 0b1000
""" should technically never occur on its own """ should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch. Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
Possible reasons for why an item should not be pulled ahead by progression balancing: progression_skip_balancing = 0b1001 # only progression gets balanced
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
deprioritized = 0b10000
""" Should technically never occur on its own.
Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
def as_flag(self) -> int: def as_flag(self) -> int:
"""As Network API flag int.""" """As Network API flag int."""
return int(self & 0b00111) return int(self & 0b0111)
class Item: class Item:
@@ -1617,10 +1498,6 @@ class Item:
def trap(self) -> bool: def trap(self) -> bool:
return ItemClassification.trap in self.classification return ItemClassification.trap in self.classification
@property
def deprioritized(self) -> bool:
return ItemClassification.deprioritized in self.classification
@property @property
def filler(self) -> bool: def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap) return not (self.advancement or self.useful or self.trap)
@@ -1857,9 +1734,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 +1742,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)
@@ -1907,8 +1778,7 @@ class Spoiler:
if self.unreachables: if self.unreachables:
outfile.write('\n\nUnreachable Progression Items:\n\n') outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write( outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable) '\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
for unreachable in sorted(self.unreachables)]))
if self.paths: if self.paths:
outfile.write('\n\nPaths:\n\n') outfile.write('\n\nPaths:\n\n')
@@ -1935,7 +1805,7 @@ class Tutorial(NamedTuple):
description: str description: str
language: str language: str
file_name: str file_name: str
link: str # unused link: str
authors: List[str] authors: List[str]

View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor, mark_raw from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
@@ -107,9 +107,7 @@ class ClientCommandProcessor(CommandProcessor):
return False return False
count = 0 count = 0
checked_count = 0 checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
lookup = self.ctx.location_names[self.ctx.game]
for location_id, location 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,87 +128,43 @@ 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 _cmd_items(self):
"""
Helper to digest a specific section of this game's datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
self.output(f"{name} for {self.ctx.game}")
for name in lookup.values():
self.output(name)
return True
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")
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
return self.output_datapackage_part("Location Names")
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
name: str) -> bool:
"""
Logs an item or location group from the player's game's datapackage.
:param group_key: Either Item or Location group to be processed.
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game: if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.") self.output("No game set, cannot determine existing items.")
return False return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\ self.output(f"Item Names for {self.ctx.game}")
.get(self.ctx.game, {}).get(group_key, {}) for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
if lookup is None: self.output(item_name)
self.output("datapackage not yet loaded, try again")
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
if filter_key: def _cmd_locations(self):
if filter_key not in lookup: """List all location names for the currently running game."""
self.output(f"Unknown {name} Group {filter_key}") if not self.ctx.game:
return False self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
self.output(f"{name}s for {name} Group \"{filter_key}\"") def _cmd_location_groups(self):
for entry in lookup[filter_key]: """List all location group names for the currently running game."""
self.output(entry) if not self.ctx.game:
else: self.output("No game set, cannot determine existing location groups.")
self.output(f"{name} Groups for {self.ctx.game}") return False
for group in lookup: self.output(f"Location Group Names for {self.ctx.game}")
self.output(group) for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
return True self.output(group_name)
@mark_raw def _cmd_ready(self):
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
"""Send ready status to server.""" """Send ready status to server."""
self.ctx.ready = not self.ctx.ready self.ctx.ready = not self.ctx.ready
if self.ctx.ready: if self.ctx.ready:
@@ -220,7 +174,6 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str): def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command""" """The default message parser to be used when parsing any messages that do not match a command"""
@@ -248,7 +201,6 @@ class CommonContext:
# noinspection PyTypeChecker # noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]: def __getitem__(self, key: str) -> typing.Mapping[int, str]:
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
return self._game_store[key] return self._game_store[key]
def __len__(self) -> int: def __len__(self) -> int:
@@ -258,7 +210,7 @@ class CommonContext:
return iter(self._game_store) return iter(self._game_store)
def __repr__(self) -> str: def __repr__(self) -> str:
return repr(self._game_store) return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str: def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is """Returns the name for an item/location id in the context of a specific game or own game if `game` is
@@ -426,8 +378,6 @@ class CommonContext:
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
# execution # execution
@@ -687,24 +637,6 @@ class CommonContext:
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data) Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage # data storage
def set_notify(self, *keys: str) -> None: def set_notify(self, *keys: str) -> None:
@@ -856,9 +788,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 ""
@@ -1005,12 +937,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = [] msgs = []
if ctx.locations_checked: if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks", msgs.append({"cmd": "LocationChecks",
@@ -1091,19 +1017,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"]) ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints() ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply": elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"] ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]: if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints() ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"): elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"] ctx.current_energy_link_value = args["value"]
if ctx.ui: if ctx.ui:

View File

@@ -1,100 +0,0 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
WORKDIR /build
# Copy and install requirements first (better caching)
COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
"setuptools>=75,<81"
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim-bookworm AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install requirements
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
gcc=4:12.2.0-3 \
libc6-dev \
libtk8.6=8.6.13-2 \
g++=4:12.2.0-3 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create and activate venv
RUN python -m venv $VIRTUAL_ENV; \
. $VIRTUAL_ENV/bin/activate
# Copy and install requirements first (better caching)
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
gunicorn==23.0.0
COPY . .
COPY --from=cython-builder /build/*.so ./
# Run ModuleUpdate
RUN python ModuleUpdate.py -y
# Purge unneeded packages
RUN apt-get purge -y \
git \
gcc \
libc6-dev \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

101
Fill.py
View File

@@ -116,23 +116,12 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else: else:
# we filled all reachable spots. # we filled all reachable spots.
if swap: if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
# try swapping this item with previously placed items in a safe way then in an unsafe way # try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe) swap_attempts = ((i, location, unsafe)
for unsafe in (False, True) for unsafe in (False, True)
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]
@@ -141,30 +130,9 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None location.item = None
placed_item.location = None placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
for previous_safe_swap_state in previous_safe_swap_state_cache: multiworld.get_filled_locations(item.player)
# If a state has already checked the location of the swap, then it cannot be used. if single_player_placement else None)
if location not in previous_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails. # to clean that up later, so there is a chance generation fails.
@@ -362,12 +330,7 @@ def fast_fill(multiworld: MultiWorld,
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
state: CollectionState,
locations: list[Location],
pool: list[Item] | None = None) -> None:
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"} multiworld.worlds[player].options.accessibility == "minimal"}
@@ -487,12 +450,6 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld, def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None: panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
)
fill_locations = sorted(multiworld.get_unfilled_locations()) fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations) multiworld.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
@@ -535,50 +492,18 @@ def distribute_items_restrictive(multiworld: MultiWorld,
single_player = multiworld.players == 1 and not multiworld.groups single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations: if prioritylocations:
regular_progression = []
deprioritized_progression = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill" # "priority fill"
# try without deprioritized items in the mix at all. This means they need to be collected into state first. maximum_exploration_state = sweep_from_pool(multiworld.state)
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression) fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
fill_restrictive(multiworld, priority_fill_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", one_item_per_player=True, allow_partial=True) name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations and regular_progression: if prioritylocations:
# 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. maximum_exploration_state = sweep_from_pool(multiworld.state)
# allow_partial should only be set if there is deprioritized progression to fall back on. fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression) single_player_placement=single_player, swap=False, on_place=mark_for_locking,
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression, name="Priority Retry", one_item_per_player=False)
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False,
allow_partial=bool(deprioritized_progression))
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
if prioritylocations and deprioritized_progression:
# retry with deprioritized items AND without one_item_per_player optimisation
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 3", one_item_per_player=False)
# restore original order of progitempool
progitempool[:] = [item for item in progitempool if not item.location]
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
@@ -965,7 +890,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set() worlds = set()
for listed_world in target_world: for listed_world in target_world:
if listed_world not in world_name_lookup: if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block.force)
continue continue
worlds.add(world_name_lookup[listed_world]) worlds.add(world_name_lookup[listed_world])
@@ -998,9 +923,9 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
if isinstance(locations, str): if isinstance(locations, str):
locations = [locations] locations = [locations]
locations_from_groups: list[str] = []
resolved_locations: list[Location] = [] resolved_locations: list[Location] = []
for target_player in worlds: for target_player in worlds:
locations_from_groups: list[str] = []
world_locations = multiworld.get_unfilled_locations(target_player) world_locations = multiworld.get_unfilled_locations(target_player)
for group in multiworld.worlds[target_player].location_name_groups: for group in multiworld.worlds[target_player].location_name_groups:
if group in locations: if group in locations:

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(argv: list[str] | None = None): def mystery_argparse():
from settings import get_settings from settings import get_settings
settings = get_settings() settings = get_settings()
defaults = settings.generator defaults = settings.generator
@@ -57,7 +57,7 @@ def mystery_argparse(argv: list[str] | None = None):
parser.add_argument("--spoiler_only", action="store_true", parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. " help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
args = parser.parse_args(argv) args = parser.parse_args()
if args.skip_output and args.spoiler_only: if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only") parser.error("Cannot mix --skip_output and --spoiler_only")
@@ -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)
@@ -189,11 +198,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else: else:
yaml[category_name][key] = option yaml[category_name][key] = option
@@ -201,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:
@@ -214,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:
@@ -236,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, ...]:
@@ -390,8 +394,6 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting: if options[option_key].supports_weighting:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
return category_dict[option_key] return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.") raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
@@ -493,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()

View File

@@ -32,7 +32,6 @@ GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425 WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object): class AdjusterWorld(object):
class AdjusterSubWorld(object): class AdjusterSubWorld(object):
def __init__(self, random): def __init__(self, random):
@@ -41,6 +40,7 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)} self.worlds = {1: self.AdjusterSubWorld(random)}
@@ -49,7 +49,6 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action): def _get_help_string(self, action):
return textwrap.dedent(action.help) return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction # See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action): class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self, def __init__(self,
@@ -365,10 +364,10 @@ def run_sprite_update():
logging.info("Done updating sprites") logging.info("Done updating sprites")
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"): def update_sprites(task, on_finish=None):
resultmessage = "" resultmessage = ""
successful = True successful = True
sprite_dir = user_path("data", "sprites", "alttp", "remote") sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True) os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
@@ -378,11 +377,11 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
on_finish(successful, resultmessage) on_finish(successful, resultmessage)
try: try:
task.update_status("Downloading remote sprites list") task.update_status("Downloading alttpr sprites list")
with urlopen(repository_url, context=ctx) as response: with urlopen('https://alttpr.com/sprites', context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8")) sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e: except Exception as e:
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e) resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False successful = False
task.queue_event(finished) task.queue_event(finished)
return return
@@ -390,13 +389,13 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
try: try:
task.update_status("Determining needed sprites") task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')] current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path)) alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"] for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites] filename not in current_sprites]
remote_filenames = [filename for (_, filename) in remote_sprites] alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames] obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e: except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % ( resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e) type(e).__name__, e)
@@ -448,7 +447,7 @@ def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.c
successful = False successful = False
if successful: if successful:
resultmessage = "Remote sprites updated successfully" resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished) task.queue_event(finished)
@@ -869,7 +868,7 @@ class SpriteSelector():
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir) open_file(self.custom_sprite_dir)
remote_frametitle = Label(self.window, text='Remote Sprites') alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window) custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites") title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -878,8 +877,8 @@ class SpriteSelector():
title_link.pack(side=LEFT) title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir) title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(remote_frametitle, self.remote_sprite_dir, self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'Remote sprites not found. Click "Update remote sprites" to download them.') 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.') 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent: if not randomOnEvent:
@@ -892,18 +891,11 @@ class SpriteSelector():
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite) button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites) button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0)) button.pack(side=RIGHT, padx=(5, 0))
repository_label = Label(frame, text='Sprite Repository:')
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
repository_entry = Entry(frame, textvariable=self.repository_url)
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite) button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT,padx=(0,5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite) button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5)) button.pack(side=LEFT, padx=(0, 5))
@@ -1063,7 +1055,7 @@ class SpriteSelector():
for i, button in enumerate(frame.buttons): for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow) button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_remote_sprites(self): def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us. # need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy() self.window.destroy()
self.parent.update() self.parent.update()
@@ -1076,8 +1068,7 @@ class SpriteSelector():
messagebox.showerror("Sprite Updater", resultmessage) messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster) SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
on_finish, self.repository_url.get())
def browse_for_sprite(self): def browse_for_sprite(self):
sprite = filedialog.askopenfilename( sprite = filedialog.askopenfilename(
@@ -1167,13 +1158,12 @@ class SpriteSelector():
os.makedirs(self.custom_sprite_dir) os.makedirs(self.custom_sprite_dir)
@property @property
def remote_sprite_dir(self): def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttp", "remote") return user_path("data", "sprites", "alttpr")
@property @property
def custom_sprite_dir(self): def custom_sprite_dir(self):
return user_path("data", "sprites", "alttp", "custom") return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False): def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid: if not sprite.valid:

View File

@@ -286,14 +286,16 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS ctx.gba_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
async def run_game(romfile): async def run_game(romfile):
from worlds.mmbn3 import MMBN3World options = Utils.get_options().get("mmbn3_options", None)
auto_start = MMBN3World.settings.rom_start if options is None:
if auto_start is True: auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
elif os.path.isfile(auto_start): elif os.path.isfile(auto_start):

66
Main.py
View File

@@ -1,11 +1,10 @@
import collections import collections
from collections.abc import Mapping
import concurrent.futures import concurrent.futures
import logging import logging
import os import os
import pickle
import tempfile import tempfile
import time import time
from typing import Any
import zipfile import zipfile
import zlib import zlib
@@ -13,9 +12,8 @@ import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from NetUtils import convert_to_base_types
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, restricted_dumps, version_tuple from Utils import __version__, output_path, version_tuple
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -37,7 +35,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 +52,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
@@ -99,15 +92,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
del local_early del local_early
del early del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.') logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions") AutoWorld.call_all(multiworld, "create_regions")
@@ -115,6 +99,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items") AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules") AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids: for player in multiworld.player_ids:
@@ -135,9 +125,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules. # Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1: if multiworld.players > 1:
locality_rules(multiworld) locality_rules(multiworld)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld) multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
@@ -181,7 +173,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.link_items() multiworld.link_items()
if any(world.options.item_links for world in multiworld.worlds.values()): if any(multiworld.item_links.values()):
multiworld._all_state = None multiworld._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando.")
@@ -246,13 +238,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
def write_multidata(): def write_multidata():
import NetUtils import NetUtils
from NetUtils import HintStatus from NetUtils import HintStatus
slot_data: dict[int, Mapping[str, Any]] = {} slot_data = {}
client_versions: dict[int, tuple[int, int, int]] = {} client_versions = {}
games: dict[int, str] = {} games = {}
minimum_versions: NetUtils.MinimumVersions = { minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
"server": AutoWorld.World.required_server_version, "clients": client_versions slot_info = {}
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]] names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot] player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -267,9 +257,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"])) group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int] precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()} for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints: dict[int, set[NetUtils.Hint]] = { precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
for slot in multiworld.player_ids: for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data() slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
@@ -326,7 +314,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere: if current_sphere:
spheres.append(dict(current_sphere)) spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData | bytes = { multidata = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -336,7 +324,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data, "er_hint_data": er_hint_data,
"precollected_items": precollected_items, "precollected_items": precollected_items,
"precollected_hints": precollected_hints, "precollected_hints": precollected_hints,
"version": (version_tuple.major, version_tuple.minor, version_tuple.build), "version": tuple(version_tuple),
"tags": ["AP"], "tags": ["AP"],
"minimum_versions": minimum_versions, "minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name, "seed_name": multiworld.seed_name,
@@ -344,13 +332,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"datapackage": data_package, "datapackage": data_package,
"race_mode": int(multiworld.is_race), "race_mode": int(multiworld.is_race),
} }
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"): multidata = zlib.compress(pickle.dumps(multidata), 9)
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format

347
MinecraftClient.py Normal file
View File

@@ -0,0 +1,347 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
from settings import get_settings
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options.java)
if not jdk_exe:
jdk_exe = shutil.which("java") # try to fall back to system java
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = get_settings().minecraft_options
channel = args.channel or options.release_channel
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options.forge_directory
max_heap = options.max_heap_size
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -5,22 +5,18 @@ import multiprocessing
import warnings import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9): if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
# Official micro version updates. This should match the number in docs/running from source.md. # Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.") raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13): elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
# There are known security issues, but no easy way to install fixed versions on Windows for testing. # There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.") warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 11, 0): elif sys.version_info < (3, 10, 1):
# Other platforms may get security backports instead of micro updates, so the number is unreliable. # Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.") raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool( _skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
getattr(sys, "frozen", False) or
multiprocessing.parent_process() or
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
)
update_ran = _skip_update update_ran = _skip_update
@@ -74,11 +70,11 @@ def update_command():
def install_pkg_resources(yes=False): def install_pkg_resources(yes=False):
try: try:
import pkg_resources # noqa: F401 import pkg_resources # noqa: F401
except (AttributeError, ImportError): except ImportError:
check_pip() check_pip()
if not yes: if not yes:
confirm("pkg_resources not found, press enter to install it") confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"]) subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None: def update(yes: bool = False, force: bool = False) -> None:

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
@@ -43,22 +43,13 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification 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[
@@ -485,7 +445,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.") raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:])) return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any], def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool): use_embedded_server_options: bool):
self.read_data = {} self.read_data = {}
@@ -586,7 +546,6 @@ class Context:
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
try: try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save()) encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f: with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save)) f.write(zlib.compress(encoded_save))
@@ -667,7 +626,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 +660,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"]
@@ -795,7 +752,7 @@ class Context:
return self.player_names[team, slot] return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False, def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None): recipients: typing.Sequence[int] = None):
"""Send and remember hints.""" """Send and remember hints."""
if only_new: if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]] hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -810,9 +767,8 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]: if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data) concerns[hint.finding_player].append(data)
# For !hint use cases, only hints that were not already found at the time of creation should be remembered # only remember hints that were not already found at the time of creation
# For LocationScouts use-cases, all hints should be remembered if not hint.found:
if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player, # since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists # we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]: if hint not in self.hints[team, hint.finding_player]:
@@ -1177,13 +1133,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 +1150,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 +1178,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 +1298,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 +1327,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 +1481,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 +1608,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 +1634,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 +1656,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,65 +1943,14 @@ 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)
if locs and create_as_hint: if locs and create_as_hint:
ctx.save() ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'CreateHints':
location_player = args.get("player", client.slot)
locations = args["locations"]
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
hints = []
for location in locations:
if location_player != client.slot and location not in ctx.locations[location_player]:
error_text = (
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
"Please refrain from hinting other slot's locations that you don't know contain your items."
)
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
target_item, item_player, flags = ctx.locations[location_player][location]
if client.slot not in ctx.slot_set(item_player):
if status != HintStatus.HINT_UNSPECIFIED:
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
if client.slot != location_player:
error_text = "CreateHints: Can only create hints for own items or own locations."
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint': elif cmd == 'UpdateHint':
location = args["location"] location = args["location"]
player = args["player"] player = args["player"]
@@ -2307,19 +2184,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 +2305,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 +2341,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 +2379,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 +2466,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 +2531,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 +2566,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

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing import typing
import enum import enum
import warnings import warnings
@@ -84,7 +83,7 @@ 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.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple): class NetworkItem(typing.NamedTuple):
@@ -107,27 +106,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder( _encode = JSONEncoder(
ensure_ascii=False, ensure_ascii=False,
check_circular=False, check_circular=False,
@@ -174,8 +152,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):
@@ -474,42 +450,6 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked]) location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore LocationStore = _LocationStore
else: else:

View File

@@ -277,7 +277,6 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS ctx.n64_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue

View File

@@ -494,30 +494,6 @@ class Choice(NumericOption):
else: else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -889,13 +865,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items()) return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any: def __getitem__(self, item: str) -> typing.Any:
return self.value[item] return self.value.__getitem__(item)
def __iter__(self) -> typing.Iterator[str]: def __iter__(self) -> typing.Iterator[str]:
return iter(self.value) return self.value.__iter__()
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
# __getitem__ fallback fails for Counters, so we define this explicitly # __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool: def __contains__(self, item) -> bool:
@@ -1091,10 +1067,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText: def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value[index] return self.value.__getitem__(index)
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return self.value.__len__()
class ConnectionsMeta(AssembleOptions): class ConnectionsMeta(AssembleOptions):
@@ -1118,7 +1094,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str entrance: str
exit: str exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100 percentage: int = 100
@@ -1241,7 +1217,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value]) connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection: def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value[index] return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]: def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value yield from self.value
@@ -1339,7 +1315,6 @@ class CommonOptions(metaclass=OptionsMetaProperty):
will be returned as a sorted list. will be returned as a sorted list.
""" """
assert option_names, "options.as_dict() was used without any option names." assert option_names, "options.as_dict() was used without any option names."
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
option_results = {} option_results = {}
for option_name in option_names: for option_name in option_names:
if option_name not in type(self).type_hints: if option_name not in type(self).type_hints:
@@ -1380,7 +1355,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 +1363,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 +1421,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 +1448,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)
@@ -1671,7 +1643,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints, item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems] StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
""" """
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group. Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
@@ -1755,10 +1727,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

@@ -7,6 +7,7 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past * The Legend of Zelda: A Link to the Past
* Factorio * Factorio
* Minecraft
* Subnautica * Subnautica
* Risk of Rain 2 * Risk of Rain 2
* The Legend of Zelda: Ocarina of Time * The Legend of Zelda: Ocarina of Time
@@ -14,12 +15,14 @@ Currently, the following games are supported:
* Super Metroid * Super Metroid
* Secret of Evermore * Secret of Evermore
* Final Fantasy * Final Fantasy
* Rogue Legacy
* VVVVVV * VVVVVV
* Raft * Raft
* Super Mario 64 * Super Mario 64
* 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
@@ -39,6 +42,7 @@ Currently, the following games are supported:
* The Messenger * The Messenger
* Kingdom Hearts 2 * Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX * The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure * Adventure
* DLC Quest * DLC Quest
* Noita * Noita
@@ -79,9 +83,6 @@ Currently, the following games are supported:
* Jak and Daxter: The Precursor Legacy * Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins * Super Mario Land 2: 6 Golden Coins
* shapez * shapez
* 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,6 @@ 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 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 +285,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = settings.get_settings().sni_options.sni_path sni_path = Utils.get_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 +668,8 @@ 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 = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
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()

126
Utils.py
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):
@@ -48,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.4" __version__ = "0.6.2"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -167,10 +166,6 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path) os.symlink(home_path.cached_path, legacy_home_path)
else: else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True) os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else: else:
# not implemented # not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -182,7 +177,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, "cached_path"):
pass pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
@@ -323,13 +318,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))
@@ -416,26 +409,13 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless @cache_argsless
def get_unique_identifier(): def get_unique_identifier():
common_path = cache_path("common.json") uuid = persistent_load().get("client", {}).get("uuid", None)
try:
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
except FileNotFoundError:
common_file = {}
uuid = None
if uuid: if uuid:
return uuid return uuid
from uuid import uuid4 import uuid
uuid = str(uuid4()) uuid = uuid.getnode()
common_file["uuid"] = uuid persistent_store("client", "uuid", uuid)
cache_folder = os.path.dirname(common_path)
os.makedirs(cache_folder, exist_ok=True)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid return uuid
@@ -458,7 +438,6 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by OptionCounter # used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter": if module == "collections" and name == "Counter":
return collections.Counter return collections.Counter
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
@@ -478,7 +457,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")
@@ -489,18 +468,6 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load() return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue: class ByValue:
""" """
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -721,22 +688,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
@@ -915,7 +873,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it, Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget" to prevent early garbage collection. "fire-and-forget"
""" """
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task # https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs: # Python docs:
# ``` # ```
# Important: Save a reference to the result of [asyncio.create_task], # Important: Save a reference to the result of [asyncio.create_task],
@@ -952,15 +910,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
@@ -968,7 +926,8 @@ def _extend_freeze_support() -> None:
# Handle the first process that MP will create # Handle the first process that MP will create
if ( if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.resource_tracker import main', 'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.forkserver import main' 'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
): ):
@@ -987,23 +946,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 +1092,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

@@ -54,15 +54,16 @@ def get_app() -> "Flask":
return app return app
def copy_tutorials_files_to_static() -> None: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil import shutil
import zipfile import zipfile
from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
@@ -71,7 +72,7 @@ def copy_tutorials_files_to_static() -> None:
shutil.rmtree(base_target_path, ignore_errors=True) shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, secure_filename(game)) target_path = os.path.join(base_target_path, get_file_safe_name(game))
os.makedirs(target_path, exist_ok=True) os.makedirs(target_path, exist_ok=True)
if world.zip_path: if world.zip_path:
@@ -84,14 +85,45 @@ def copy_tutorials_files_to_static() -> None:
for zfile in zf.infolist(): for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename: if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename) zfile.filename = os.path.basename(zfile.filename)
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f: zf.extract(zfile, target_path)
f.write(zf.read(zfile))
else: else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path) files = os.listdir(source_path)
for file in files: for file in files:
shutil.copyfile(Utils.local_path(source_path, file), shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
Utils.local_path(target_path, secure_filename(file)))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
if __name__ == "__main__": if __name__ == "__main__":
@@ -99,25 +131,18 @@ 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() create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:
autohost(app.config) autohost(app.config)
if app.config["SELFGEN"]: if app.config["SELFGEN"]:

View File

@@ -1,7 +1,6 @@
import base64 import base64
import os import os
import socket import socket
import typing
import uuid import uuid
from flask import Flask from flask import Flask
@@ -62,44 +61,30 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value: uuid.UUID) -> str:
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value: str) -> uuid.UUID: def to_python(self, value):
return to_python(value) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(self, value: typing.Any) -> str: def to_url(self, value):
assert isinstance(value, uuid.UUID) return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
return to_url(value)
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters["suuid"] = to_url app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register() -> None: def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib
from werkzeug.utils import find_modules
# has automatic patch integration # has automatic patch integration
import worlds.Files import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
for module in find_modules("WebHostLib", include_packages=True):
importlib.import_module(module)
from . import api
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)] return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
# trigger endpoint registration
from . import datapackage, generate, room, tracker, user from . import datapackage, generate, room, user # trigger registration

View File

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

View File

@@ -3,7 +3,6 @@ from uuid import UUID
from flask import abort, url_for from flask import abort, url_for
from WebHostLib import to_url
import worlds.Files import worlds.Files
from . import api_endpoints, get_players from . import api_endpoints, get_players
from ..models import Room from ..models import Room
@@ -34,7 +33,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
downloads.append(slot_download) downloads.append(slot_download)
return { return {
"tracker": to_url(room.tracker), "tracker": room.tracker,
"players": get_players(room.seed), "players": get_players(room.seed),
"last_port": room.last_port, "last_port": room.last_port,
"last_activity": room.last_activity, "last_activity": room.last_activity,

View File

@@ -1,241 +0,0 @@
from datetime import datetime, timezone
from typing import Any, TypedDict
from uuid import UUID
from flask import abort
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
from WebHostLib import cache
from WebHostLib.api import api_endpoints
from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
class PlayerLocationsTotal(TypedDict):
team: int
player: int
total_locations: int
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
for player in players:
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player."""
for team, players in all_players.items():
for player in players:
player_items_received.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
for player in players:
player_checks_done.append(
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints
activity_timers: list[PlayerTimer] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
activity_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for entry in activity_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
connection_timers: list[PlayerTimer] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
connection_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
# find the matching entry
for entry in connection_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
player_status: list[PlayerStatus] = []
"""The current client status for each player."""
for team, players in all_players.items():
for player in players:
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
"total_checks_done": total_checks_done,
"hints": hints,
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
}
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
slot_info = tracker_data.get_slot_info(player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
break
player_locations_total: list[PlayerLocationsTotal] = []
for team, players in all_players.items():
for player in players:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data

View File

@@ -1,7 +1,6 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select from pony.orm import select
from WebHostLib import to_url
from WebHostLib.models import Room, Seed from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players from . import api_endpoints, get_players
@@ -11,13 +10,13 @@ def get_rooms():
response = [] response = []
for room in select(room for room in Room if room.owner == session["_id"]): for room in select(room for room in Room if room.owner == session["_id"]):
response.append({ response.append({
"room_id": to_url(room.id), "room_id": room.id,
"seed_id": to_url(room.seed.id), "seed_id": room.seed.id,
"creation_time": room.creation_time, "creation_time": room.creation_time,
"last_activity": room.last_activity, "last_activity": room.last_activity,
"last_port": room.last_port, "last_port": room.last_port,
"timeout": room.timeout, "timeout": room.timeout,
"tracker": to_url(room.tracker), "tracker": room.tracker,
}) })
return jsonify(response) return jsonify(response)
@@ -27,7 +26,7 @@ def get_seeds():
response = [] response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]): for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({ response.append({
"seed_id": to_url(seed.id), "seed_id": seed.id,
"creation_time": seed.creation_time, "creation_time": seed.creation_time,
"players": get_players(seed), "players": get_players(seed),
}) })

View File

@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event() _stop_event = Event()
def stop() -> None: def stop():
"""Stops previously launched threads""" """Stops previously launched threads"""
global _stop_event global _stop_event
stop_event = _stop_event stop_event = _stop_event
@@ -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,13 +157,16 @@ 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.")
Thread(target=keep_running, name="AP_Autogen").start() Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance(): class MultiworldInstance():
def __init__(self, config: dict, id: int): def __init__(self, config: dict, id: int):
self.room_ids = set() self.room_ids = set()

View File

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

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

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else: else:
import io import io
if slot_data.game == "Factorio": if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):

View File

@@ -1,29 +1,30 @@
import concurrent.futures import concurrent.futures
import json import json
import os import os
import pickle
import random import random
import tempfile import tempfile
import zipfile import zipfile
from collections import Counter from collections import Counter
from pickle import PicklingError from typing import Any, Dict, List, Optional, Union, Set
from typing import Any
from flask import flash, redirect, render_template, request, session, url_for 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__
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
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]: def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: set[str] = set() plando_options: Set[str] = set()
for substr in ("bosses", "items", "connections", "texts"): for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options): if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr) plando_options.add(substr)
@@ -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,11 +73,7 @@ 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: def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
return f"{e.__class__.__name__}: {e}"
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"]))
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
@@ -87,40 +83,30 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
f"If you have a larger group, please generate it yourself and upload it.") f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {}))) return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]: elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
try: gen = Generation(
gen = Generation( options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible
# convert to json compatible meta=json.dumps(meta),
meta=json.dumps(meta), state=STATE_QUEUED,
state=STATE_QUEUED, owner=session["_id"])
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
commit() commit()
return redirect(url_for("wait_seed", seed=gen.id)) return redirect(url_for("wait_seed", seed=gen.id))
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: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if meta is None: if not meta:
meta = {} meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False) race = meta.setdefault("generator_options", {}).setdefault("race", False)
@@ -137,47 +123,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([]) # Just to set up the Namespace with defaults 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 +167,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 +179,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 +196,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
@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites from LttPAdjuster import update_sprites
# Target directories # Target directories
input_dir = user_path("data", "sprites", "alttp", "remote") input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True) os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)

View File

@@ -1,90 +0,0 @@
import re
from collections import Counter
import mistune
from werkzeug.utils import secure_filename
__all__ = [
"ImgUrlRewriteInlineParser",
'render_markdown',
]
class ImgUrlRewriteInlineParser(mistune.InlineParser):
relative_url_base: str
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
super().__init__(hard_wrap)
self.relative_url_base = relative_url_base
@staticmethod
def _find_game_name_by_folder_name(name: str) -> str | None:
from worlds.AutoWorld import AutoWorldRegister
for world_name, world_type in AutoWorldRegister.world_types.items():
if world_type.__module__ == f"worlds.{name}":
return world_name
return None
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
res = super().parse_link(m, state)
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
image_token = state.tokens[-1]
url: str = image_token["attrs"]["url"]
if not url.startswith("/") and not "://" in url:
# replace relative URL to another world's doc folder with the webhost folder layout
if url.startswith("../../") and "/docs/" in self.relative_url_base:
parts = url.split("/", 4)
if parts[2] != ".." and parts[3] == "docs":
game_name = self._find_game_name_by_folder_name(parts[2])
if game_name is not None:
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
# change relative URL to point to deployment folder
url = f"{self.relative_url_base}/{url}"
image_token['attrs']['url'] = url
return res
def render_markdown(path: str, img_url_base: str | None = None) -> str:
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
# there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
if img_url_base:
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
html = markdown(document)
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
return html

View File

@@ -7,27 +7,17 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
def get_world_theme(game_name: str) -> str: def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types: if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme return AutoWorldRegister.world_types[game_name].web.theme
return 'grass' return 'grass'
def get_visible_worlds() -> dict[str, type(World)]:
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return worlds
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound) @app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err): def page_not_found(err):
@@ -41,101 +31,83 @@ def start_playing():
return render_template(f"startPlaying.html") return render_template(f"startPlaying.html")
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>') @app.route('/games/<string:game>/info/<string:lang>')
@cache.cached() @cache.cached()
def game_info(game, lang): def game_info(game, lang):
"""Game Info Pages"""
try: try:
theme = get_world_theme(game) world = AutoWorldRegister.world_types[game]
secure_game_name = secure_filename(game) if lang not in world.web.game_info_languages:
lang = secure_filename(lang) raise KeyError("Sorry, this game's info page is not available in that language yet.")
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name) except KeyError:
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404) return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games') @app.route('/games')
@cache.cached() @cache.cached()
def games(): def games():
"""List of supported games""" worlds = {}
return render_template("supportedGames.html", worlds=get_visible_worlds()) for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
@app.route('/tutorial/<string:game>/<string:file>') return render_template("supportedGames.html", worlds=worlds)
@cache.cached()
def tutorial(game: str, file: str):
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial_redirect(game: str, file: str, lang: str): @cache.cached()
""" def tutorial(game, file, lang):
Permanent redirect old tutorial URLs to new ones to keep search engines happy. try:
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en world = AutoWorldRegister.world_types[game]
""" if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301) raise KeyError("Sorry, the tutorial is not available in that language yet.")
except KeyError:
return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/') @app.route('/tutorial/')
@cache.cached() @cache.cached()
def tutorial_landing(): def tutorial_landing():
tutorials = {} return render_template("tutorialLanding.html")
worlds = AutoWorldRegister.world_types
for world_name, world_type in worlds.items():
current_world = tutorials[world_name] = {}
for tutorial in world_type.web.tutorials:
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
"description": tutorial.description, "files": {}})
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
"authors": tutorial.authors,
"language": tutorial.language
}
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@app.route('/faq/<string:lang>/') @app.route('/faq/<string:lang>/')
@cache.cached() @cache.cached()
def faq(lang: str): def faq(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) import markdown
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title="Frequently Asked Questions", title="Frequently Asked Questions",
html_from_markdown=document, html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
) )
@app.route('/glossary/<string:lang>/') @app.route('/glossary/<string:lang>/')
@cache.cached() @cache.cached()
def glossary(lang: str): def glossary(lang: str):
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) import markdown
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title="Glossary", title="Glossary",
html_from_markdown=document, html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
) )
@@ -216,10 +188,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"
@@ -227,9 +196,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
@@ -240,9 +209,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,13 +1,12 @@
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.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py Flask-Compress>=1.17
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5 setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.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

@@ -0,0 +1,45 @@
window.addEventListener('load', () => {
const gameInfo = document.getElementById('game-info');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, this game's info page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the info page.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].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,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;
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
}; };
ajax.open('GET', url);
ajax.send();
}, 15000)
let updateTracker = () => { // Collapsible advancement sections
const ajax = new XMLHttpRequest(); const categories = document.getElementsByClassName("location-category");
ajax.onreadystatechange = () => { for (let category of categories) {
if (ajax.readyState !== 4) { return; } let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
// Create a fake DOM using the returned HTML continue;
const domParser = new DOMParser(); }
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html'); category.addEventListener('click', function() {
// Toggle the advancement list
// Update dynamic sections document.getElementById(hide_id).classList.toggle("hide");
updateSection('player-info', fakeDOM); // Change text of the header
updateSection('section-filler', fakeDOM); const tab_header = document.getElementById(hide_id+'_header').children[0];
updateSection('section-terran', fakeDOM); const orig_text = tab_header.innerHTML;
updateSection('section-zerg', fakeDOM); let new_text;
updateSection('section-protoss', fakeDOM); if (orig_text.includes("▼")) {
updateSection('section-nova', fakeDOM); new_text = orig_text.replace("▼", "▲");
updateSection('section-kerrigan', fakeDOM); }
updateSection('section-keys', fakeDOM); else {
updateSection('section-locations', fakeDOM); new_text = orig_text.replace("▲", "▼");
}; }
ajax.open('GET', url); tab_header.innerHTML = new_text;
ajax.send(); });
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000); }
};
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
}); });

View File

@@ -0,0 +1,52 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('tutorial-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -0,0 +1,81 @@
const showError = () => {
const tutorial = document.getElementById('tutorial-landing');
document.getElementById('page-title').innerText = 'This page is out of logic!';
tutorial.removeChild(document.getElementById('loading'));
const userMessage = document.createElement('h3');
const homepageLink = document.createElement('a');
homepageLink.innerText = 'Click here';
homepageLink.setAttribute('href', '/');
userMessage.append(homepageLink);
userMessage.append(' to go back to safety!');
tutorial.append(userMessage);
};
window.addEventListener('load', () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
const tutorialDiv = document.getElementById('tutorial-landing');
if (ajax.status !== 200) { return showError(); }
try {
const games = JSON.parse(ajax.responseText);
games.forEach((game) => {
const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => {
const tutorialName = document.createElement('h3');
tutorialName.innerText = tutorial.name;
tutorialDiv.appendChild(tutorialName);
const tutorialDescription = document.createElement('p');
tutorialDescription.innerText = tutorial.description;
tutorialDiv.appendChild(tutorialDescription);
const intro = document.createElement('p');
intro.innerText = 'This guide is available in the following languages:';
tutorialDiv.appendChild(intro);
const fileList = document.createElement('ul');
tutorial.files.forEach((file) => {
const listItem = document.createElement('li');
const anchor = document.createElement('a');
anchor.innerText = file.language;
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
listItem.appendChild(anchor);
listItem.append(' by ');
for (let author of file.authors) {
listItem.append(author);
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
listItem.append(', ');
}
}
fileList.appendChild(listItem);
});
tutorialDiv.appendChild(fileList);
});
});
tutorialDiv.removeChild(document.getElementById('loading'));
} catch (error) {
showError();
console.error(error);
}
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
};
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -28,6 +28,7 @@
font-weight: normal; font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif; font-family: LondrinaSolid-Regular, sans-serif;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%; width: 100%;
text-shadow: 1px 1px 4px #000000; text-shadow: 1px 1px 4px #000000;
} }
@@ -36,6 +37,7 @@
font-size: 38px; font-size: 38px;
font-weight: normal; font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif; font-family: LondrinaSolid-Light, sans-serif;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%; width: 100%;
margin-top: 20px; margin-top: 20px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -48,6 +50,7 @@
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
text-align: left; text-align: left;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%; width: 100%;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -56,6 +59,7 @@
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 24px; font-size: 24px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -63,12 +67,14 @@
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 22px; font-size: 22px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
} }
.markdown h6, .markdown details summary.h6{ .markdown h6, .markdown details summary.h6{
font-family: LexendDeca-Regular, sans-serif; font-family: LexendDeca-Regular, sans-serif;
text-transform: none; text-transform: none;
font-size: 20px; font-size: 20px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
} }
.markdown h4, .markdown h5, .markdown h6{ .markdown h4, .markdown h5, .markdown h6{

View File

@@ -0,0 +1,102 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#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: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

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

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

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Info</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
<!-- Populated my JS / MD -->
</div>
{% endblock %}

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

@@ -26,15 +26,15 @@
<td>{{ patch.game }}</td> <td>{{ patch.game }}</td>
<td> <td>
{% if patch.data %} {% if patch.data %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %} {% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a> Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a> Download APSM64EX File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %} {% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>

View File

@@ -1,8 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{% set theme_name = theme|default("grass", true) %} {% include 'header/grassHeader.html' %}
{% include "header/"+theme_name+"Header.html" %}
<title>{{ title }}</title> <title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %} {% endblock %}

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

@@ -11,32 +11,32 @@
<h1>Site Map</h1> <h1>Site Map</h1>
<h2>Base Pages</h2> <h2>Base Pages</h2>
<ul> <ul>
<li><a href="{{ url_for('discord') }}">Discord Link</a></li> <li><a href="/discord">Discord Link</a></li>
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li> <li><a href="/faq/en">F.A.Q. Page</a></li>
<li><a href="{{ url_for('favicon') }}">Favicon</a></li> <li><a href="/favicon.ico">Favicon</a></li>
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li> <li><a href="/generate">Generate Game Page</a></li>
<li><a href="{{ url_for('landing') }}">Homepage</a></li> <li><a href="/">Homepage</a></li>
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li> <li><a href="/uploads">Host Game Page</a></li>
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li> <li><a href="/datapackage">Raw Data Package</a></li>
<li><a href="{{ url_for('check') }}">Settings Validator</a></li> <li><a href="{{ url_for('check')}}">Settings Validator</a></li>
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li> <li><a href="/sitemap">Site Map</a></li>
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li> <li><a href="/start-playing">Start Playing</a></li>
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li> <li><a href="/games">Supported Games Page</a></li>
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li> <li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="{{ url_for('user_content') }}">User Content</a></li> <li><a href="/user-content">User Content</a></li>
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li> <li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li> <li><a href="/glossary/en">Glossary</a></li>
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li> <li><a href="{{url_for("show_session")}}">Session / Login</a></li>
</ul> </ul>
<h2>Tutorials</h2> <h2>Tutorials</h2>
<ul> <ul>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li> <li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li> <li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li> <li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li> <li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li> <li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li> <li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
</ul> </ul>
<h2>Game Info Pages</h2> <h2>Game Info Pages</h2>

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">

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/'+theme+'Header.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -3,32 +3,14 @@
{% block head %} {% block head %}
{% include 'header/grassHeader.html' %} {% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title> <title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="tutorial-landing" class="markdown"> <div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
<h1>Archipelago Guides</h1> <h1 id="page-title">Archipelago Guides</h1>
{% for world_name, world_type in worlds.items() %} <p id="loading">Loading...</p>
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
<h3>{{ tutorial_name }}</h3>
<p>{{ tutorial_data.description }}</p>
<p>This guide is available in the following languages:</p>
<ul>
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}

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

@@ -1,3 +1,4 @@
import base64
import json import json
import pickle import pickle
import typing import typing
@@ -13,8 +14,9 @@ from pony.orm.core import TransactionIntegrityError
import schema import schema
import MultiServer import MultiServer
from NetUtils import GamesPackage, SlotType from NetUtils import SlotType
from Utils import VersionException, __version__ from Utils import VersionException, __version__
from worlds import GamesPackage
from worlds.Files import AutoPatchRegister from worlds.Files import AutoPatchRegister
from worlds.AutoWorld import data_package_checksum from worlds.AutoWorld import data_package_checksum
from . import app from . import app
@@ -133,6 +135,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.") flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio # Factorio
elif file.filename.endswith(".zip"): elif file.filename.endswith(".zip"):

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"
@@ -335,7 +333,6 @@ async def nes_sync_task(ctx: ZeldaContext):
except ConnectionRefusedError: except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again") logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS ctx.nes_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue continue
@@ -343,12 +340,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

@@ -1,3 +0,0 @@
pytest>=8.4.2,<9 # pytest 9.0.0 is broken for our CI
pytest-xdist>=3.8.0
pytest-subtests>=0.15.0 # will not be required anymore once we upgrade to pytest 9.x

View File

@@ -24,20 +24,9 @@
<BaseButton>: <BaseButton>:
ripple_color: app.theme_cls.primaryColor ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2 ripple_duration_in_fast: 0.2
<MDNavigationItemBase>: <MDTabsItemBase>:
on_release: app.screens.switch_screens(self) ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>: <TooltipLabel>:
adaptive_height: True adaptive_height: True
theme_font_size: "Custom" theme_font_size: "Custom"
@@ -220,8 +209,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 +222,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

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection. -- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then if (frame % 120 == 0) then
server:settimeout(120) server:settimeout(2)
local client, timeout = server:accept() local client, timeout = server:accept()
if timeout == nil then if timeout == nil then
print('Initial Connection Made') print('Initial Connection Made')

BIN
data/mcicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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.
@@ -50,9 +46,7 @@ requires:
{{ yaml_dump(game) }}: {{ yaml_dump(game) }}:
{%- for group_name, group_options in option_groups.items() %} {%- for group_name, group_options in option_groups.items() %}
##{% for _ in group_name %}#{% endfor %}## # {{ group_name }}
# {{ group_name }} #
##{% for _ in group_name %}#{% endfor %}##
{%- for option_key, option in group_options.items() %} {%- for option_key, option in group_options.items() %}
{{ option_key }}: {{ option_key }}:

2
data/sprites/custom/.gitignore vendored Normal file
View File

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

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

View File

@@ -1,61 +0,0 @@
services:
multiworld:
# Build only once. Web service uses the same image build
build:
context: ..
# Name image for use in web service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch main process without website hosting (config override)
entrypoint: python WebHost.py --config_override selflaunch.yaml
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_selflaunch.yaml:/app/selflaunch.yaml
# Expose on host network for access to dynamically mapped ports
network_mode: host
# No Healthcheck in place yet for multiworld
healthcheck:
test: ["NONE"]
web:
# Use image build by multiworld service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch gunicorn targeting WebHost application
entrypoint: gunicorn -c gunicorn.conf.py
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_gunicorn.conf.py:/app/gunicorn.conf.py
environment:
# Bind gunicorn on 8000
- PORT=8000
nginx:
image: nginx:stable-alpine
volumes:
# Mount application volume
- app_volume:/app
# Mount config
- ./example_nginx.conf:/etc/nginx/nginx.conf
ports:
# Nginx listening internally on port 80 -- mapped to 8080 on host
- 8080:80
depends_on:
- web
volumes:
# Share application directory amongst multiworld and web services
# (for access to log files and the like), and nginx (for static files)
app_volume:

View File

@@ -1,14 +0,0 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# We'll start a separate process for rooms and generators
SELFLAUNCH: false
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
# Set as your local IP (192.168.x.x) to serve over LAN.
HOST_ADDRESS: localhost
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
# the proprietary assets in WebHostLib
#ASSET_RIGHTS: false

View File

@@ -1,19 +0,0 @@
workers = 2
threads = 2
wsgi_app = "WebHost:get_app()"
accesslog = "-"
access_log_format = (
'%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
)
worker_class = "gthread" # "sync" | "gthread"
forwarded_allow_ips = "*"
loglevel = "info"
"""
You can programatically set values.
For example, set number of workers to half of the cpu count:
import multiprocessing
workers = multiprocessing.cpu_count() / 2
"""

View File

@@ -1,64 +0,0 @@
worker_processes 1;
user nobody nogroup;
# 'user nobody nobody;' for systems with 'nobody' as a group instead
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024; # increase if you have lots of clients
accept_mutex off; # set to 'on' if nginx worker_processes > 1
# 'use epoll;' to enable for Linux 2.6+
# 'use kqueue;' to enable for FreeBSD, OSX
use epoll;
}
http {
include mime.types;
# fallback in case we can't determine a type
default_type application/octet-stream;
access_log /var/log/nginx/access.log combined;
sendfile on;
upstream app_server {
# fail_timeout=0 means we always retry an upstream even if it failed
# to return a good HTTP response
# for UNIX domain socket setups
# server unix:/tmp/gunicorn.sock fail_timeout=0;
# for a TCP configuration
server web:8000 fail_timeout=0;
}
server {
# use 'listen 80 deferred;' for Linux
# use 'listen 80 accept_filter=httpready;' for FreeBSD
listen 80 deferred;
client_max_body_size 4G;
# set the correct host(s) for your site
# server_name example.com www.example.com;
keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
# we don't want nginx trying to do something clever with
# redirects, we set the Host: header above already.
proxy_redirect off;
proxy_pass http://app_server;
}
}
}

View File

@@ -1,13 +0,0 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# Start room and generator processes
SELFLAUNCH: true
JOB_THRESHOLD: 0
# Maximum concurrent world gens
GENERATORS: 3
# Rooms will be spread across multiple processes
HOSTERS: 4

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,18 +42,15 @@
# 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
# Clique
/worlds/clique/ @ThePhar
# Dark Souls III # Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3 /worlds/dark_souls_3/ @Marechal-L @nex3
@@ -72,9 +72,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
@@ -124,6 +121,9 @@
# The Messenger # The Messenger
/worlds/messenger/ @alwaysintreble /worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2 # Mega Man 2
/worlds/mm2/ @Silvris /worlds/mm2/ @Silvris
@@ -142,9 +142,6 @@
# Overcooked! 2 # Overcooked! 2
/worlds/overcooked2/ @toasterparty /worlds/overcooked2/ @toasterparty
# Paint
/worlds/paint/ @MarioManTAW
# Pokemon Emerald # Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe /worlds/pokemon_emerald/ @Zunawe
@@ -154,6 +151,9 @@
# Raft # Raft
/worlds/raft/ @SunnyBat /worlds/raft/ @SunnyBat
# Rogue Legacy
/worlds/rogue_legacy/ @ThePhar
# Risk of Rain 2 # Risk of Rain 2
/worlds/ror2/ @kindasneaki /worlds/ror2/ @kindasneaki
@@ -206,7 +206,7 @@
/worlds/timespinner/ @Jarno458 /worlds/timespinner/ @Jarno458
# The Legend of Zelda (1) # The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A /worlds/tloz/ @Rosalie-A @t3hf1gm3nt
# TUNIC # TUNIC
/worlds/tunic/ @silent-destroyer @ScipioWright /worlds/tunic/ @silent-destroyer @ScipioWright
@@ -244,6 +244,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

@@ -16,7 +16,7 @@ game contributions:
* **Do not introduce unit test failures/regressions.** * **Do not introduce unit test failures/regressions.**
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
your changes. Currently, the oldest supported version your changes. Currently, the oldest supported version
is [Python 3.11](https://www.python.org/downloads/release/python-31113/). is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
It is recommended that automated github actions are turned on in your fork to have github run unit tests after It is recommended that automated github actions are turned on in your fork to have github run unit tests after
pushing. pushing.
You can turn them on here: You can turn them on here:

View File

@@ -1,92 +0,0 @@
# Deploy Using Containers
If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version.
To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md).
Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy.
## Building the Container Image
What you'll need:
* A container runtime engine such as:
* [Docker](https://www.docker.com/) (Version 23.0 or later)
* [Podman](https://podman.io/) (version 4.0 or later)
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
`docker build -t archipelago .`
Or:
`podman build -t archipelago .`
It is recommended to tag the image using `-t` to more easily identify the image and run it.
## Running the Container
Running the container can be performed using:
`docker run --network host archipelago`
Or:
`podman run --network host archipelago`
The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`.
Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially:
`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml`
See `docs/webhost configuration sample.yaml` for example.
## Using Docker Compose
An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container.
To deploy in this manner, from the ["deploy"](../deploy) directory, run:
`docker compose up -d`
### Services
The `docker-compose.yaml` file defines three services:
* multiworld:
* Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service.
* web:
* Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it.
* We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service.
* No ports are exposed through to the host.
* nginx:
* Serves as a reverse proxy with `web` as its upstream.
* Directs all HTTP traffic from port 80 to the upstream service.
* Exposed to the host on port 8080. This is where we can reach the website.
### Configuration
As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network.
The configuration files may be modified to handle for machine-specific optimizations, such as:
* Web pages responding too slowly
* Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count.
* Game generation stalls
* Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml)
* Gameplay lags
* Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml)
Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`.
## Windows
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
Enemizer is not currently available for `aarch64`.
## Optional: Git
Building the image requires a local copy of the ArchipelagoMW source code.
Refer to [Running From Source](running%20from%20source.md#optional-git).

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