mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-29 13:03:23 -07:00
Compare commits
4 Commits
0.5.0
...
fix-text-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fe779add | ||
|
|
95af4554d5 | ||
|
|
e95a850f11 | ||
|
|
1b8b256886 |
61
.github/workflows/build.yml
vendored
61
.github/workflows/build.yml
vendored
@@ -36,15 +36,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
choco install innosetup --version=6.2.2 --allow-downgrade
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python setup.py build_exe --yes
|
python setup.py build_exe --yes
|
||||||
if ( $? -eq $false ) {
|
|
||||||
Write-Error "setup.py failed!"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
echo "$NAME -> $ZIP_NAME"
|
echo "$NAME -> $ZIP_NAME"
|
||||||
@@ -54,6 +49,12 @@ jobs:
|
|||||||
Rename-Item "exe.$NAME" Archipelago
|
Rename-Item "exe.$NAME" Archipelago
|
||||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Store 7z
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.ZIP_NAME }}
|
||||||
|
path: dist/${{ env.ZIP_NAME }}
|
||||||
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
- name: Build Setup
|
- name: Build Setup
|
||||||
run: |
|
run: |
|
||||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
@@ -64,38 +65,11 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
- name: Check build loads expected worlds
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd build/exe*
|
|
||||||
mv Players/Templates/meta.yaml .
|
|
||||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
|
||||||
rm -R Players/Templates
|
|
||||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
|
||||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
|
||||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
|
||||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
|
||||||
mv meta.yaml Players/Templates/
|
|
||||||
- name: Test Generate
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd build/exe*
|
|
||||||
cp Players/Templates/Clique.yaml Players/
|
|
||||||
timeout 30 ./ArchipelagoGenerate
|
|
||||||
- name: Store 7z
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ env.ZIP_NAME }}
|
|
||||||
path: dist/${{ env.ZIP_NAME }}
|
|
||||||
compression-level: 0 # .7z is incompressible by zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
|
||||||
- name: Store Setup
|
- name: Store Setup
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ env.SETUP_NAME }}
|
name: ${{ env.SETUP_NAME }}
|
||||||
path: setups/${{ env.SETUP_NAME }}
|
path: setups/${{ env.SETUP_NAME }}
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2004:
|
||||||
@@ -136,7 +110,7 @@ jobs:
|
|||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
# - copy code above to release.yml -
|
||||||
@@ -144,36 +118,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
python setup.py build_exe --yes
|
python setup.py build_exe --yes
|
||||||
- name: Check build loads expected worlds
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd build/exe*
|
|
||||||
mv Players/Templates/meta.yaml .
|
|
||||||
ls -1 Players/Templates | sort > setup-player-templates.txt
|
|
||||||
rm -R Players/Templates
|
|
||||||
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
|
|
||||||
ls -1 Players/Templates | sort > generated-player-templates.txt
|
|
||||||
cmp setup-player-templates.txt generated-player-templates.txt \
|
|
||||||
|| diff setup-player-templates.txt generated-player-templates.txt
|
|
||||||
mv meta.yaml Players/Templates/
|
|
||||||
- name: Test Generate
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd build/exe*
|
|
||||||
cp Players/Templates/Clique.yaml Players/
|
|
||||||
timeout 30 ./ArchipelagoGenerate
|
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ env.APPIMAGE_NAME }}
|
name: ${{ env.APPIMAGE_NAME }}
|
||||||
path: dist/${{ env.APPIMAGE_NAME }}
|
path: dist/${{ env.APPIMAGE_NAME }}
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Store .tar.gz
|
- name: Store .tar.gz
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ env.TAR_NAME }}
|
name: ${{ env.TAR_NAME }}
|
||||||
path: dist/${{ env.TAR_NAME }}
|
path: dist/${{ env.TAR_NAME }}
|
||||||
compression-level: 0 # .gz is incompressible by zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
54
.github/workflows/ctest.yml
vendored
54
.github/workflows/ctest.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
# Run CMake / CTest C++ unit tests
|
|
||||||
|
|
||||||
name: ctest
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.cc?'
|
|
||||||
- '**.cpp'
|
|
||||||
- '**.cxx'
|
|
||||||
- '**.hh?'
|
|
||||||
- '**.hpp'
|
|
||||||
- '**.hxx'
|
|
||||||
- '**.CMakeLists'
|
|
||||||
- '.github/workflows/ctest.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.cc?'
|
|
||||||
- '**.cpp'
|
|
||||||
- '**.cxx'
|
|
||||||
- '**.hh?'
|
|
||||||
- '**.hpp'
|
|
||||||
- '**.hxx'
|
|
||||||
- '**.CMakeLists'
|
|
||||||
- '.github/workflows/ctest.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ctest:
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
name: Test C++ ${{ matrix.os }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: ilammy/msvc-dev-cmd@v1
|
|
||||||
if: startsWith(matrix.os,'windows')
|
|
||||||
- uses: Bacondish2023/setup-googletest@v1
|
|
||||||
with:
|
|
||||||
build-type: 'Release'
|
|
||||||
- name: Build tests
|
|
||||||
run: |
|
|
||||||
cd test/cpp
|
|
||||||
mkdir build
|
|
||||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
|
||||||
cmake --build build/ --config Release
|
|
||||||
ls
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
cd test/cpp
|
|
||||||
ctest --test-dir build/ -C Release --output-on-failure
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
|||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
|
|||||||
31
.github/workflows/unittests.yml
vendored
31
.github/workflows/unittests.yml
vendored
@@ -24,7 +24,7 @@ on:
|
|||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
build:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||||
|
|
||||||
@@ -60,32 +60,3 @@ jobs:
|
|||||||
- name: Unittests
|
- name: Unittests
|
||||||
run: |
|
run: |
|
||||||
pytest -n auto
|
pytest -n auto
|
||||||
|
|
||||||
hosting:
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os:
|
|
||||||
- ubuntu-latest
|
|
||||||
python:
|
|
||||||
- {version: '3.11'} # current
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up Python ${{ matrix.python.version }}
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python.version }}
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
|
||||||
- name: Test hosting
|
|
||||||
run: |
|
|
||||||
source venv/bin/activate
|
|
||||||
export PYTHONPATH=$(pwd)
|
|
||||||
python test/hosting/__main__.py
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,7 +62,6 @@ Output Logs/
|
|||||||
/installdelete.iss
|
/installdelete.iss
|
||||||
/data/user.kv
|
/data/user.kv
|
||||||
/datapackage
|
/datapackage
|
||||||
/custom_worlds
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -178,7 +177,6 @@ dmypy.json
|
|||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# Cython intermediates
|
# Cython intermediates
|
||||||
_speedups.c
|
|
||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
|
|||||||
self.local_item_locations = {}
|
self.local_item_locations = {}
|
||||||
self.dragon_speed_info = {}
|
self.dragon_speed_info = {}
|
||||||
|
|
||||||
options = Utils.get_settings()
|
options = Utils.get_options()
|
||||||
self.display_msgs = options["adventure_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):
|
||||||
@@ -102,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 Utils.get_settings()["adventure_options"].get("death_link", False):
|
if Utils.get_options()["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":
|
||||||
@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
|
|||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
elif cmd == "ReceivedItems":
|
elif cmd == "ReceivedItems":
|
||||||
msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
|
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
elif cmd == "Retrieved":
|
elif cmd == "Retrieved":
|
||||||
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
|
||||||
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
104
CommonClient.py
104
CommonClient.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -9,7 +8,6 @@ import sys
|
|||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
import functools
|
import functools
|
||||||
import warnings
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -23,7 +21,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
from MultiServer import CommandProcessor
|
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, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -175,77 +173,10 @@ class CommonContext:
|
|||||||
items_handling: typing.Optional[int] = None
|
items_handling: typing.Optional[int] = None
|
||||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||||
|
|
||||||
class NameLookupDict:
|
# data package
|
||||||
"""A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
|
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||||
def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
self.ctx: CommonContext = ctx
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
|
||||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
|
||||||
self.warned: bool = False
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
|
||||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
|
||||||
if isinstance(key, int):
|
|
||||||
if not self.warned:
|
|
||||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
|
||||||
self.warned = True
|
|
||||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
|
||||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
|
||||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
|
||||||
return self._flat_store[key] # type: ignore
|
|
||||||
|
|
||||||
return self._game_store[key]
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self._game_store)
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
|
||||||
return iter(self._game_store)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return self._game_store.__repr__()
|
|
||||||
|
|
||||||
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
|
|
||||||
omitted.
|
|
||||||
"""
|
|
||||||
if game_name is None:
|
|
||||||
game_name = self.ctx.game
|
|
||||||
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
|
|
||||||
|
|
||||||
return self._game_store[game_name][code]
|
|
||||||
|
|
||||||
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
|
|
||||||
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
|
|
||||||
omitted.
|
|
||||||
|
|
||||||
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
|
|
||||||
`ctx.game` and use `lookup_in_game` method instead.
|
|
||||||
"""
|
|
||||||
if slot is None:
|
|
||||||
slot = self.ctx.slot
|
|
||||||
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
|
|
||||||
|
|
||||||
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
|
|
||||||
|
|
||||||
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
|
|
||||||
"""Overrides existing lookup tables for a particular game."""
|
|
||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
|
||||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
|
||||||
if game == "Archipelago":
|
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
|
||||||
# it updates in all chain maps automatically.
|
|
||||||
self._archipelago_lookup.clear()
|
|
||||||
self._archipelago_lookup.update(id_to_name_lookup_table)
|
|
||||||
|
|
||||||
# defaults
|
# defaults
|
||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
@@ -300,7 +231,7 @@ class CommonContext:
|
|||||||
# message box reporting a loss of connection
|
# message box reporting a loss of connection
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.username = None
|
self.username = None
|
||||||
@@ -340,9 +271,6 @@ class CommonContext:
|
|||||||
self.exit_event = asyncio.Event()
|
self.exit_event = asyncio.Event()
|
||||||
self.watcher_event = asyncio.Event()
|
self.watcher_event = asyncio.Event()
|
||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
@@ -496,11 +424,6 @@ class CommonContext:
|
|||||||
"""Gets called before sending a Say to the server from the user.
|
"""Gets called before sending a Say to the server from the user.
|
||||||
Returned text is sent, or sending is aborted if None is returned."""
|
Returned text is sent, or sending is aborted if None is returned."""
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def on_ui_command(self, text: str) -> None:
|
|
||||||
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
|
|
||||||
The command processor is still called; this is just intended for command echoing."""
|
|
||||||
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
|
|
||||||
|
|
||||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||||
for permission_name, permission_flag in permissions.items():
|
for permission_name, permission_flag in permissions.items():
|
||||||
@@ -563,17 +486,19 @@ class CommonContext:
|
|||||||
or remote_checksum != cache_checksum:
|
or remote_checksum != cache_checksum:
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game)
|
||||||
if needed_updates:
|
if needed_updates:
|
||||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
|
||||||
|
|
||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.item_names[item_id] = item_name
|
||||||
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
|
self.location_names[location_id] = location_name
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
self.update_game(game_data, game)
|
self.update_game(game_data)
|
||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
@@ -862,8 +787,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.team = args["team"]
|
ctx.team = args["team"]
|
||||||
ctx.slot = args["slot"]
|
ctx.slot = args["slot"]
|
||||||
# int keys get lost in JSON transfer
|
# int keys get lost in JSON transfer
|
||||||
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
|
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||||
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
|
|
||||||
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}")
|
||||||
|
|||||||
6
Fill.py
6
Fill.py
@@ -483,15 +483,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||||
swap=True,
|
swap=True,
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||||
swap=False,
|
swap=False,
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||||
swap=False, allow_partial=True,
|
swap=False, allow_partial=True,
|
||||||
name="Progression", single_player_placement=multiworld.players == 1)
|
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||||
|
|||||||
72
Generate.py
72
Generate.py
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import sys
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -17,16 +15,23 @@ import ModuleUpdate
|
|||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
import copy
|
||||||
import Utils
|
import Utils
|
||||||
import Options
|
import Options
|
||||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||||
|
from Main import main as ERmain
|
||||||
|
from settings import get_settings
|
||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
|
from worlds.alttp.Text import TextTable
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from worlds.generic import PlandoConnection
|
||||||
|
from worlds import failed_world_loads
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
from settings import get_settings
|
options = get_settings()
|
||||||
settings = get_settings()
|
defaults = options.generator
|
||||||
defaults = settings.generator
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||||
@@ -38,7 +43,7 @@ def mystery_argparse():
|
|||||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
||||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||||
parser.add_argument('--outputpath', default=settings.general_options.output_path,
|
parser.add_argument('--outputpath', default=options.general_options.output_path,
|
||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||||
@@ -58,23 +63,20 @@ def mystery_argparse():
|
|||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
return args
|
return args, options
|
||||||
|
|
||||||
|
|
||||||
def get_seed_name(random_source) -> str:
|
def get_seed_name(random_source) -> str:
|
||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
def main(args=None, callback=ERmain):
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
|
||||||
|
|
||||||
if not args:
|
if not args:
|
||||||
args = mystery_argparse()
|
args, options = mystery_argparse()
|
||||||
|
else:
|
||||||
|
options = get_settings()
|
||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
|
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
@@ -143,9 +145,6 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
raise Exception(f"No weights found. "
|
raise Exception(f"No weights found. "
|
||||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.plando_options = args.plando
|
erargs.plando_options = args.plando
|
||||||
@@ -237,7 +236,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||||
yaml.dump(important, f)
|
yaml.dump(important, f)
|
||||||
|
|
||||||
return erargs, seed
|
return callback(erargs, seed)
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||||
@@ -362,8 +361,6 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
if game in AutoWorldRegister.world_types:
|
if game in AutoWorldRegister.world_types:
|
||||||
@@ -441,13 +438,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
@@ -474,7 +468,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
|
|
||||||
ret.game = get_choice("game", weights)
|
ret.game = get_choice("game", weights)
|
||||||
if ret.game not in AutoWorldRegister.world_types:
|
if ret.game not in AutoWorldRegister.world_types:
|
||||||
from worlds import failed_world_loads
|
|
||||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||||
if picks[0] in failed_world_loads:
|
if picks[0] in failed_world_loads:
|
||||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||||
@@ -513,12 +506,35 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if PlandoOptions.items in plando_options:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
roll_alttp_settings(ret, game_weights)
|
roll_alttp_settings(ret, game_weights, plando_options)
|
||||||
|
if PlandoOptions.connections in plando_options:
|
||||||
|
ret.plando_connections = []
|
||||||
|
options = game_weights.get("plando_connections", [])
|
||||||
|
for placement in options:
|
||||||
|
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||||
|
ret.plando_connections.append(PlandoConnection(
|
||||||
|
get_choice("entrance", placement),
|
||||||
|
get_choice("exit", placement),
|
||||||
|
get_choice("direction", placement, "both")
|
||||||
|
))
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def roll_alttp_settings(ret: argparse.Namespace, weights):
|
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||||
|
|
||||||
|
ret.plando_texts = {}
|
||||||
|
if PlandoOptions.texts in plando_options:
|
||||||
|
tt = TextTable()
|
||||||
|
tt.removeUnwantedText()
|
||||||
|
options = weights.get("plando_texts", [])
|
||||||
|
for placement in options:
|
||||||
|
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||||
|
at = str(get_choice_legacy("at", placement))
|
||||||
|
if at not in tt:
|
||||||
|
raise Exception(f"No text target \"{at}\" found.")
|
||||||
|
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||||
|
|
||||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||||
if 'random_sprite_on_event' in weights:
|
if 'random_sprite_on_event' in weights:
|
||||||
@@ -546,9 +562,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import atexit
|
import atexit
|
||||||
confirmation = atexit.register(input, "Press enter to close.")
|
confirmation = atexit.register(input, "Press enter to close.")
|
||||||
erargs, seed = main()
|
multiworld = main()
|
||||||
from Main import main as ERmain
|
|
||||||
multiworld = ERmain(erargs, seed)
|
|
||||||
if __debug__:
|
if __debug__:
|
||||||
import gc
|
import gc
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
82
Launcher.py
82
Launcher.py
@@ -19,7 +19,7 @@ import sys
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Sequence, Union, Optional
|
from typing import Sequence, Union, Optional
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import settings
|
import settings
|
||||||
@@ -160,12 +160,8 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
def run_gui():
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
||||||
from kivy.core.window import Window
|
|
||||||
from kivy.uix.image import AsyncImage
|
from kivy.uix.image import AsyncImage
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivy.uix.relativelayout import RelativeLayout
|
||||||
|
|
||||||
@@ -173,8 +169,11 @@ def run_gui():
|
|||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
container: ContainerLayout
|
||||||
grid: GridLayout
|
grid: GridLayout
|
||||||
_tool_layout: Optional[ScrollBox] = None
|
|
||||||
_client_layout: Optional[ScrollBox] = None
|
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||||
|
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||||
|
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||||
|
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None):
|
||||||
self.title = self.base_title
|
self.title = self.base_title
|
||||||
@@ -182,7 +181,18 @@ def run_gui():
|
|||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _refresh_components(self) -> None:
|
def build(self):
|
||||||
|
self.container = ContainerLayout()
|
||||||
|
self.grid = GridLayout(cols=2)
|
||||||
|
self.container.add_widget(self.grid)
|
||||||
|
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||||
|
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||||
|
tool_layout = ScrollBox()
|
||||||
|
tool_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(tool_layout)
|
||||||
|
client_layout = ScrollBox()
|
||||||
|
client_layout.layout.orientation = "vertical"
|
||||||
|
self.grid.add_widget(client_layout)
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_button(component: Component) -> Widget:
|
||||||
"""
|
"""
|
||||||
@@ -207,49 +217,14 @@ def run_gui():
|
|||||||
return box_layout
|
return box_layout
|
||||||
return button
|
return button
|
||||||
|
|
||||||
# clear before repopulating
|
|
||||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
|
||||||
tool_children = reversed(self._tool_layout.layout.children)
|
|
||||||
for child in tool_children:
|
|
||||||
self._tool_layout.layout.remove_widget(child)
|
|
||||||
client_children = reversed(self._client_layout.layout.children)
|
|
||||||
for child in client_children:
|
|
||||||
self._client_layout.layout.remove_widget(child)
|
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||||
_tools.items(), _miscs.items(), _adjusters.items()
|
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||||
), _clients.items()):
|
|
||||||
# column 1
|
# column 1
|
||||||
if tool:
|
if tool:
|
||||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
tool_layout.layout.add_widget(build_button(tool[1]))
|
||||||
# column 2
|
# column 2
|
||||||
if client:
|
if client:
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
client_layout.layout.add_widget(build_button(client[1]))
|
||||||
|
|
||||||
def build(self):
|
|
||||||
self.container = ContainerLayout()
|
|
||||||
self.grid = GridLayout(cols=2)
|
|
||||||
self.container.add_widget(self.grid)
|
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
|
||||||
self._tool_layout = ScrollBox()
|
|
||||||
self._tool_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._tool_layout)
|
|
||||||
self._client_layout = ScrollBox()
|
|
||||||
self._client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._client_layout)
|
|
||||||
|
|
||||||
self._refresh_components()
|
|
||||||
|
|
||||||
global refresh_components
|
|
||||||
refresh_components = self._refresh_components
|
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
@@ -260,14 +235,6 @@ def run_gui():
|
|||||||
else:
|
else:
|
||||||
launch(get_exe(button.component), button.component.cli)
|
launch(get_exe(button.component), button.component.cli)
|
||||||
|
|
||||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
|
||||||
""" When a patch file is dropped into the window, run the associated component. """
|
|
||||||
file, component = identify(filename.decode())
|
|
||||||
if file and component:
|
|
||||||
run_component(component, file)
|
|
||||||
else:
|
|
||||||
logging.warning(f"unable to identify component for {filename}")
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
# Closing the window explicitly cleans it up.
|
# Closing the window explicitly cleans it up.
|
||||||
@@ -276,17 +243,10 @@ def run_gui():
|
|||||||
|
|
||||||
Launcher().run()
|
Launcher().run()
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
|
||||||
# and don't try to do something with widgets after window closed
|
|
||||||
global refresh_components
|
|
||||||
refresh_components = None
|
|
||||||
|
|
||||||
|
|
||||||
def run_component(component: Component, *args):
|
def run_component(component: Component, *args):
|
||||||
if component.func:
|
if component.func:
|
||||||
component.func(*args)
|
component.func(*args)
|
||||||
if refresh_components:
|
|
||||||
refresh_components()
|
|
||||||
elif component.script_name:
|
elif component.script_name:
|
||||||
subprocess.run([*get_exe(component.script_name), *args])
|
subprocess.run([*get_exe(component.script_name), *args])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
|
||||||
import copy
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
@@ -38,7 +37,7 @@ except ImportError:
|
|||||||
|
|
||||||
import NetUtils
|
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
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore
|
SlotType, LocationStore
|
||||||
|
|
||||||
@@ -169,15 +168,13 @@ class Context:
|
|||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
generator_version = Version(0, 0, 0)
|
generator_version = Version(0, 0, 0)
|
||||||
checksums: typing.Dict[str, str]
|
checksums: typing.Dict[str, str]
|
||||||
item_names: typing.Dict[str, typing.Dict[int, str]] = (
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
|
|
||||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
location_names: typing.Dict[str, typing.Dict[int, str]] = (
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
|
|
||||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
||||||
""" each sphere is { player: { location_id, ... } } """
|
""" each sphere is { player: { location_id, ... } } """
|
||||||
logger: logging.Logger
|
logger: logging.Logger
|
||||||
@@ -232,7 +229,7 @@ class Context:
|
|||||||
self.embedded_blacklist = {"host", "port"}
|
self.embedded_blacklist = {"host", "port"}
|
||||||
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
|
||||||
self.auto_save_interval = 60 # in seconds
|
self.auto_save_interval = 60 # in seconds
|
||||||
self.auto_saver_thread: typing.Optional[threading.Thread] = None
|
self.auto_saver_thread = None
|
||||||
self.save_dirty = False
|
self.save_dirty = False
|
||||||
self.tags = ['AP']
|
self.tags = ['AP']
|
||||||
self.games: typing.Dict[int, str] = {}
|
self.games: typing.Dict[int, str] = {}
|
||||||
@@ -269,31 +266,19 @@ class Context:
|
|||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||||
|
|
||||||
for game_package in self.gamespackage.values():
|
|
||||||
# remove groups from data sent to clients
|
|
||||||
del game_package["item_name_groups"]
|
|
||||||
del game_package["location_name_groups"]
|
|
||||||
|
|
||||||
def _init_game_data(self):
|
def _init_game_data(self):
|
||||||
for game_name, game_package in self.gamespackage.items():
|
for game_name, game_package in self.gamespackage.items():
|
||||||
if "checksum" in game_package:
|
if "checksum" in game_package:
|
||||||
self.checksums[game_name] = game_package["checksum"]
|
self.checksums[game_name] = game_package["checksum"]
|
||||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
self.item_names[game_name][item_id] = item_name
|
self.item_names[item_id] = item_name
|
||||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
self.location_names[game_name][location_id] = location_name
|
self.location_names[location_id] = location_name
|
||||||
self.all_item_and_group_names[game_name] = \
|
self.all_item_and_group_names[game_name] = \
|
||||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
self.all_location_and_group_names[game_name] = \
|
self.all_location_and_group_names[game_name] = \
|
||||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||||
|
|
||||||
archipelago_item_names = self.item_names["Archipelago"]
|
|
||||||
archipelago_location_names = self.location_names["Archipelago"]
|
|
||||||
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
|
|
||||||
# Add Archipelago items and locations to each data package.
|
|
||||||
self.item_names[game].update(archipelago_item_names)
|
|
||||||
self.location_names[game].update(archipelago_location_names)
|
|
||||||
|
|
||||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
@@ -798,7 +783,10 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
for slot, connected_clients in clients.items():
|
for slot, connected_clients in clients.items():
|
||||||
if connected_clients:
|
if connected_clients:
|
||||||
name = ctx.player_names[team, slot]
|
name = ctx.player_names[team, slot]
|
||||||
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
players.append(
|
||||||
|
NetworkPlayer(team, slot,
|
||||||
|
ctx.name_aliases.get((team, slot), name), name)
|
||||||
|
)
|
||||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||||
games.add("Archipelago")
|
games.add("Archipelago")
|
||||||
await ctx.send_msgs(client, [{
|
await ctx.send_msgs(client, [{
|
||||||
@@ -813,6 +801,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
'permissions': get_permissions(ctx),
|
'permissions': get_permissions(ctx),
|
||||||
'hint_cost': ctx.hint_cost,
|
'hint_cost': ctx.hint_cost,
|
||||||
'location_check_points': ctx.location_check_points,
|
'location_check_points': ctx.location_check_points,
|
||||||
|
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||||
|
in ctx.gamespackage.items() if game in games},
|
||||||
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||||
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||||
'seed_name': ctx.seed_name,
|
'seed_name': ctx.seed_name,
|
||||||
@@ -1016,8 +1006,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
info_text = json_format_send_event(new_item, target_player)
|
||||||
ctx.broadcast_team(team, [info_text])
|
ctx.broadcast_team(team, [info_text])
|
||||||
|
|
||||||
@@ -1071,8 +1061,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
|||||||
|
|
||||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
f"{ctx.item_names[hint.item]} is " \
|
||||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
f"at {ctx.location_names[hint.location]} " \
|
||||||
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
||||||
|
|
||||||
if hint.entrance:
|
if hint.entrance:
|
||||||
@@ -1101,6 +1091,28 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
|||||||
"item": net_item}
|
"item": net_item}
|
||||||
|
|
||||||
|
|
||||||
|
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||||
|
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
|
if len(picks) > 1:
|
||||||
|
dif = picks[0][1] - picks[1][1]
|
||||||
|
if picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] < 75:
|
||||||
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
|
elif dif > 5:
|
||||||
|
return picks[0][0], True, "Close Match"
|
||||||
|
else:
|
||||||
|
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
|
||||||
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
|
else:
|
||||||
|
if picks[0][1] > 90:
|
||||||
|
return picks[0][0], True, "Only Option Match"
|
||||||
|
else:
|
||||||
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
|
|
||||||
|
|
||||||
class CommandMeta(type):
|
class CommandMeta(type):
|
||||||
def __new__(cls, name, bases, attrs):
|
def __new__(cls, name, bases, attrs):
|
||||||
commands = attrs["commands"] = {}
|
commands = attrs["commands"] = {}
|
||||||
@@ -1352,7 +1364,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for item_id in remaining_item_ids))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
@@ -1365,7 +1377,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for item_id in remaining_item_ids))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
@@ -1383,8 +1395,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
game = self.ctx.slot_info[self.client.slot].game
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
names = [self.ctx.location_names[game][location] for location in locations]
|
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||||
if filter_text in location_groups: # location group name
|
if filter_text in location_groups: # location group name
|
||||||
@@ -1409,8 +1420,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
game = self.ctx.slot_info[self.client.slot].game
|
names = [self.ctx.location_names[location] for location in locations]
|
||||||
names = [self.ctx.location_names[game][location] for location in locations]
|
|
||||||
if filter_text:
|
if filter_text:
|
||||||
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
|
||||||
if filter_text in location_groups: # location group name
|
if filter_text in location_groups: # location group name
|
||||||
@@ -1491,10 +1501,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
elif input_text.isnumeric():
|
elif input_text.isnumeric():
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
hint_id = int(input_text)
|
hint_id = int(input_text)
|
||||||
hint_name = self.ctx.item_names[game][hint_id] \
|
hint_name = self.ctx.item_names[hint_id] \
|
||||||
if not for_location and hint_id in self.ctx.item_names[game] \
|
if not for_location and hint_id in self.ctx.item_names \
|
||||||
else self.ctx.location_names[game][hint_id] \
|
else self.ctx.location_names[hint_id] \
|
||||||
if for_location and hint_id in self.ctx.location_names[game] \
|
if for_location and hint_id in self.ctx.location_names \
|
||||||
else None
|
else None
|
||||||
if hint_name in self.ctx.non_hintable_names[game]:
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
@@ -1932,6 +1942,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Shutdown the server"""
|
"""Shutdown the server"""
|
||||||
self.ctx.server.ws_server.close()
|
self.ctx.server.ws_server.close()
|
||||||
|
if self.ctx.shutdown_task:
|
||||||
|
self.ctx.shutdown_task.cancel()
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2289,8 +2301,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
async def auto_shutdown(ctx, to_cancel=None):
|
async def auto_shutdown(ctx, to_cancel=None):
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
await asyncio.sleep(ctx.auto_shutdown)
|
||||||
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
|
|
||||||
|
|
||||||
def inactivity_shutdown():
|
def inactivity_shutdown():
|
||||||
ctx.server.ws_server.close()
|
ctx.server.ws_server.close()
|
||||||
@@ -2310,8 +2321,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
|||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
inactivity_shutdown()
|
inactivity_shutdown()
|
||||||
else:
|
else:
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
await asyncio.sleep(seconds)
|
||||||
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||||
|
|||||||
@@ -198,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
"slateblue": "6D8BE8",
|
"slateblue": "6D8BE8",
|
||||||
"plum": "AF99EF",
|
"plum": "AF99EF",
|
||||||
"salmon": "FA8072",
|
"salmon": "FA8072",
|
||||||
"white": "FFFFFF",
|
"white": "FFFFFF"
|
||||||
"orange": "FF7700",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
@@ -248,7 +247,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
|
|
||||||
def _handle_item_id(self, node: JSONMessagePart):
|
def _handle_item_id(self, node: JSONMessagePart):
|
||||||
item_id = int(node["text"])
|
item_id = int(node["text"])
|
||||||
node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
|
node["text"] = self.ctx.item_names[item_id]
|
||||||
return self._handle_item_name(node)
|
return self._handle_item_name(node)
|
||||||
|
|
||||||
def _handle_location_name(self, node: JSONMessagePart):
|
def _handle_location_name(self, node: JSONMessagePart):
|
||||||
@@ -256,8 +255,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
return self._handle_color(node)
|
return self._handle_color(node)
|
||||||
|
|
||||||
def _handle_location_id(self, node: JSONMessagePart):
|
def _handle_location_id(self, node: JSONMessagePart):
|
||||||
location_id = int(node["text"])
|
item_id = int(node["text"])
|
||||||
node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
|
node["text"] = self.ctx.location_names[item_id]
|
||||||
return self._handle_location_name(node)
|
return self._handle_location_name(node)
|
||||||
|
|
||||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||||
|
|||||||
303
Options.py
303
Options.py
@@ -12,7 +12,6 @@ from copy import deepcopy
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from schema import And, Optional, Or, Schema
|
from schema import And, Optional, Or, Schema
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
from Utils import get_fuzzy_results, is_iterable_except_str
|
from Utils import get_fuzzy_results, is_iterable_except_str
|
||||||
|
|
||||||
@@ -53,8 +52,8 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
|
||||||
@@ -126,28 +125,10 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
# can be weighted between selections
|
# can be weighted between selections
|
||||||
supports_weighting = True
|
supports_weighting = True
|
||||||
|
|
||||||
rich_text_doc: typing.Optional[bool] = None
|
|
||||||
"""Whether the WebHost should render the Option's docstring as rich text.
|
|
||||||
|
|
||||||
If this is True, the Option's docstring is interpreted as reStructuredText_,
|
|
||||||
the standard Python markup format. In the WebHost, it's rendered to HTML so
|
|
||||||
that lists, emphasis, and other rich text features are displayed properly.
|
|
||||||
|
|
||||||
If this is False, the docstring is instead interpreted as plain text, and
|
|
||||||
displayed as-is on the WebHost with whitespace preserved.
|
|
||||||
|
|
||||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
|
||||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
|
||||||
set it to True and use reStructuredText for their Option documentation.
|
|
||||||
|
|
||||||
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
# filled by AssembleOptions:
|
# filled by AssembleOptions:
|
||||||
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
|
||||||
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore
|
||||||
options: typing.ClassVar[typing.Dict[str, int]]
|
options: typing.ClassVar[typing.Dict[str, int]]
|
||||||
aliases: typing.ClassVar[typing.Dict[str, int]]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||||
@@ -753,12 +734,6 @@ class NamedRange(Range):
|
|||||||
elif value > self.range_end and value not in self.special_range_names.values():
|
elif value > self.range_end and value not in self.special_range_names.values():
|
||||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||||
|
|
||||||
# See docstring
|
|
||||||
for key in self.special_range_names:
|
|
||||||
if key != key.lower():
|
|
||||||
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
|
|
||||||
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
|
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -921,237 +896,12 @@ class ItemSet(OptionSet):
|
|||||||
convert_name_groups = True
|
convert_name_groups = True
|
||||||
|
|
||||||
|
|
||||||
class PlandoText(typing.NamedTuple):
|
|
||||||
at: str
|
|
||||||
text: typing.List[str]
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
PlandoTextsFromAnyType = typing.Union[
|
|
||||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
display_name = "Plando Texts"
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if self.value and not (PlandoOptions.texts & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando texts module is turned off, "
|
|
||||||
f"so text for {player_name} will be ignored.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
|
|
||||||
texts: typing.List[PlandoText] = []
|
|
||||||
if isinstance(data, typing.Iterable):
|
|
||||||
for text in data:
|
|
||||||
if isinstance(text, typing.Mapping):
|
|
||||||
if random.random() < float(text.get("percentage", 100)/100):
|
|
||||||
at = text.get("at", None)
|
|
||||||
if at is not None:
|
|
||||||
given_text = text.get("text", [])
|
|
||||||
if isinstance(given_text, str):
|
|
||||||
given_text = [given_text]
|
|
||||||
texts.append(PlandoText(
|
|
||||||
at,
|
|
||||||
given_text,
|
|
||||||
text.get("percentage", 100)
|
|
||||||
))
|
|
||||||
elif isinstance(text, PlandoText):
|
|
||||||
if random.random() < float(text.percentage/100):
|
|
||||||
texts.append(text)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
|
||||||
cls.verify_keys([text.at for text in texts])
|
|
||||||
return cls(texts)
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
|
|
||||||
return str({text.at: " ".join(text.text) for text in value})
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoText]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return self.value.__len__()
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
|
||||||
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
|
|
||||||
if name != "PlandoConnections":
|
|
||||||
assert "entrances" in attrs, f"Please define valid entrances for {name}"
|
|
||||||
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
|
|
||||||
assert "exits" in attrs, f"Please define valid exits for {name}"
|
|
||||||
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
|
|
||||||
if "__doc__" not in attrs:
|
|
||||||
attrs["__doc__"] = PlandoConnections.__doc__
|
|
||||||
cls = super().__new__(mcs, name, bases, attrs)
|
|
||||||
return cls
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoConnection(typing.NamedTuple):
|
|
||||||
class Direction:
|
|
||||||
entrance = "entrance"
|
|
||||||
exit = "exit"
|
|
||||||
both = "both"
|
|
||||||
|
|
||||||
entrance: str
|
|
||||||
exit: str
|
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
PlandoConFromAnyType = typing.Union[
|
|
||||||
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
|
|
||||||
"""Generic connections plando. Format is:
|
|
||||||
- entrance: "Entrance Name"
|
|
||||||
exit: "Exit Name"
|
|
||||||
direction: "Direction"
|
|
||||||
percentage: 100
|
|
||||||
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
|
|
||||||
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
|
|
||||||
|
|
||||||
display_name = "Plando Connections"
|
|
||||||
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
|
|
||||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
|
||||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
|
||||||
|
|
||||||
duplicate_exits: bool = False
|
|
||||||
"""Whether or not exits should be allowed to be duplicate."""
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoConnection]):
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super(PlandoConnections, self).__init__()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_entrance_name(cls, entrance: str) -> bool:
|
|
||||||
return entrance.lower() in cls.entrances
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_exit_name(cls, exit: str) -> bool:
|
|
||||||
return exit.lower() in cls.exits
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def can_connect(cls, entrance: str, exit: str) -> bool:
|
|
||||||
"""Checks that a given entrance can connect to a given exit.
|
|
||||||
By default, this will always return true unless overridden."""
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
|
|
||||||
used_entrances: typing.List[str] = []
|
|
||||||
used_exits: typing.List[str] = []
|
|
||||||
for connection in connections:
|
|
||||||
entrance = connection.entrance
|
|
||||||
exit = connection.exit
|
|
||||||
direction = connection.direction
|
|
||||||
if direction not in (PlandoConnection.Direction.entrance,
|
|
||||||
PlandoConnection.Direction.exit,
|
|
||||||
PlandoConnection.Direction.both):
|
|
||||||
raise ValueError(f"Unknown direction: {direction}")
|
|
||||||
if entrance in used_entrances:
|
|
||||||
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
|
|
||||||
if not cls.duplicate_exits and exit in used_exits:
|
|
||||||
raise ValueError(f"Duplicate Exit {exit} not allowed.")
|
|
||||||
used_entrances.append(entrance)
|
|
||||||
used_exits.append(exit)
|
|
||||||
if not cls.validate_entrance_name(entrance):
|
|
||||||
raise ValueError(f"{entrance.title()} is not a valid entrance.")
|
|
||||||
if not cls.validate_exit_name(exit):
|
|
||||||
raise ValueError(f"{exit.title()} is not a valid exit.")
|
|
||||||
if not cls.can_connect(entrance, exit):
|
|
||||||
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
|
||||||
if not isinstance(data, typing.Iterable):
|
|
||||||
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
|
|
||||||
|
|
||||||
value: typing.List[PlandoConnection] = []
|
|
||||||
for connection in data:
|
|
||||||
if isinstance(connection, typing.Mapping):
|
|
||||||
percentage = connection.get("percentage", 100)
|
|
||||||
if random.random() < float(percentage / 100):
|
|
||||||
entrance = connection.get("entrance", None)
|
|
||||||
if is_iterable_except_str(entrance):
|
|
||||||
entrance = random.choice(sorted(entrance))
|
|
||||||
exit = connection.get("exit", None)
|
|
||||||
if is_iterable_except_str(exit):
|
|
||||||
exit = random.choice(sorted(exit))
|
|
||||||
direction = connection.get("direction", "both")
|
|
||||||
|
|
||||||
if not entrance or not exit:
|
|
||||||
raise Exception("Plando connection must have an entrance and an exit.")
|
|
||||||
value.append(PlandoConnection(
|
|
||||||
entrance,
|
|
||||||
exit,
|
|
||||||
direction,
|
|
||||||
percentage
|
|
||||||
))
|
|
||||||
elif isinstance(connection, PlandoConnection):
|
|
||||||
if random.random() < float(connection.percentage / 100):
|
|
||||||
value.append(connection)
|
|
||||||
else:
|
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
|
||||||
cls.validate_plando_connections(value)
|
|
||||||
return cls(value)
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if self.value and not (PlandoOptions.connections & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando connections module is turned off, "
|
|
||||||
f"so connections for {player_name} will be ignored.")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
|
|
||||||
return ", ".join(["%s %s %s" % (connection.entrance,
|
|
||||||
"<=>" if connection.direction == PlandoConnection.Direction.both else
|
|
||||||
"<=" if connection.direction == PlandoConnection.Direction.exit else
|
|
||||||
"=>",
|
|
||||||
connection.exit) for connection in value])
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
"""Set rules for reachability of your items/locations.
|
"""Set rules for reachability of your items/locations.
|
||||||
|
Locations: ensure everything can be reached and acquired.
|
||||||
- **Locations:** ensure everything can be reached and acquired.
|
Items: ensure all logically relevant items can be acquired.
|
||||||
- **Items:** ensure all logically relevant items can be acquired.
|
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||||
- **Minimal:** ensure what is needed to reach your goal can be acquired.
|
|
||||||
"""
|
|
||||||
display_name = "Accessibility"
|
display_name = "Accessibility"
|
||||||
rich_text_doc = True
|
|
||||||
option_locations = 0
|
option_locations = 0
|
||||||
option_items = 1
|
option_items = 1
|
||||||
option_minimal = 2
|
option_minimal = 2
|
||||||
@@ -1161,14 +911,11 @@ class Accessibility(Choice):
|
|||||||
|
|
||||||
class ProgressionBalancing(NamedRange):
|
class ProgressionBalancing(NamedRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
|
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
A lower setting means more getting stuck. A higher setting means less getting stuck.
|
|
||||||
"""
|
|
||||||
default = 50
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
display_name = "Progression Balancing"
|
display_name = "Progression Balancing"
|
||||||
rich_text_doc = True
|
|
||||||
special_range_names = {
|
special_range_names = {
|
||||||
"disabled": 0,
|
"disabled": 0,
|
||||||
"normal": 50,
|
"normal": 50,
|
||||||
@@ -1233,36 +980,29 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
class LocalItems(ItemSet):
|
class LocalItems(ItemSet):
|
||||||
"""Forces these items to be in their native world."""
|
"""Forces these items to be in their native world."""
|
||||||
display_name = "Local Items"
|
display_name = "Local Items"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class NonLocalItems(ItemSet):
|
class NonLocalItems(ItemSet):
|
||||||
"""Forces these items to be outside their native world."""
|
"""Forces these items to be outside their native world."""
|
||||||
display_name = "Non-local Items"
|
display_name = "Not Local Items"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartInventory(ItemDict):
|
class StartInventory(ItemDict):
|
||||||
"""Start with these items."""
|
"""Start with these items."""
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
"""Start with these items and don't place them in the world.
|
"""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.
|
|
||||||
"""
|
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory from Pool"
|
display_name = "Start Inventory from Pool"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class StartHints(ItemSet):
|
class StartHints(ItemSet):
|
||||||
"""Start with these item's locations prefilled into the ``!hint`` command."""
|
"""Start with these item's locations prefilled into the !hint command."""
|
||||||
display_name = "Start Hints"
|
display_name = "Start Hints"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class LocationSet(OptionSet):
|
class LocationSet(OptionSet):
|
||||||
@@ -1271,33 +1011,28 @@ class LocationSet(OptionSet):
|
|||||||
|
|
||||||
|
|
||||||
class StartLocationHints(LocationSet):
|
class StartLocationHints(LocationSet):
|
||||||
"""Start with these locations and their item prefilled into the ``!hint`` command."""
|
"""Start with these locations and their item prefilled into the !hint command"""
|
||||||
display_name = "Start Location Hints"
|
display_name = "Start Location Hints"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class ExcludeLocations(LocationSet):
|
class ExcludeLocations(LocationSet):
|
||||||
"""Prevent these locations from having an important item."""
|
"""Prevent these locations from having an important item"""
|
||||||
display_name = "Excluded Locations"
|
display_name = "Excluded Locations"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class PriorityLocations(LocationSet):
|
class PriorityLocations(LocationSet):
|
||||||
"""Prevent these locations from having an unimportant item."""
|
"""Prevent these locations from having an unimportant item"""
|
||||||
display_name = "Priority Locations"
|
display_name = "Priority Locations"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class DeathLink(Toggle):
|
class DeathLink(Toggle):
|
||||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||||
display_name = "Death Link"
|
display_name = "Death Link"
|
||||||
rich_text_doc = True
|
|
||||||
|
|
||||||
|
|
||||||
class ItemLinks(OptionList):
|
class ItemLinks(OptionList):
|
||||||
"""Share part of your item pool with other players."""
|
"""Share part of your item pool with other players."""
|
||||||
display_name = "Item Links"
|
display_name = "Item Links"
|
||||||
rich_text_doc = True
|
|
||||||
default = []
|
default = []
|
||||||
schema = Schema([
|
schema = Schema([
|
||||||
{
|
{
|
||||||
@@ -1312,8 +1047,7 @@ class ItemLinks(OptionList):
|
|||||||
])
|
])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
|
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||||
allow_item_groups: bool = True) -> typing.Set:
|
|
||||||
pool = set()
|
pool = set()
|
||||||
for item_name in items:
|
for item_name in items:
|
||||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||||
@@ -1364,7 +1098,6 @@ class ItemLinks(OptionList):
|
|||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
|
||||||
default = ""
|
default = ""
|
||||||
visibility = Visibility.none
|
visibility = Visibility.none
|
||||||
|
|
||||||
@@ -1467,18 +1200,14 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
return data, notes
|
return data, notes
|
||||||
|
|
||||||
def yaml_dump_scalar(scalar) -> str:
|
|
||||||
# yaml dump may add end of document marker and newlines.
|
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
grouped_options = get_option_groups(world)
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
res = Template(file_data).render(
|
res = Template(file_data).render(
|
||||||
option_groups=option_groups,
|
option_groups=grouped_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
|||||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||||
toDraw = ""
|
toDraw = ""
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
if i < len(str(ctx.item_names.lookup_in_game(l.item))):
|
if i < len(str(ctx.item_names[l.item])):
|
||||||
toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
|
toDraw += str(ctx.item_names[l.item])[i]
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
f.write(toDraw)
|
f.write(toDraw)
|
||||||
|
|||||||
47
Utils.py
47
Utils.py
@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.5.0"
|
__version__ = "0.4.6"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -458,15 +458,6 @@ class KeyedDefaultDict(collections.defaultdict):
|
|||||||
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
default_factory: typing.Callable[[Any], Any] = None,
|
|
||||||
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
|
|
||||||
**kwargs):
|
|
||||||
if seq is not None:
|
|
||||||
super().__init__(default_factory, seq, **kwargs)
|
|
||||||
else:
|
|
||||||
super().__init__(default_factory, **kwargs)
|
|
||||||
|
|
||||||
def __missing__(self, key):
|
def __missing__(self, key):
|
||||||
self[key] = value = self.default_factory(key)
|
self[key] = value = self.default_factory(key)
|
||||||
return value
|
return value
|
||||||
@@ -553,7 +544,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
f"Archipelago ({__version__}) logging initialized"
|
f"Archipelago ({__version__}) logging initialized"
|
||||||
f" on {platform.platform()}"
|
f" on {platform.platform()}"
|
||||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||||
f"{' (frozen)' if is_frozen() else ''}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -629,41 +619,6 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
|
||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
|
||||||
if len(picks) > 1:
|
|
||||||
dif = picks[0][1] - picks[1][1]
|
|
||||||
if picks[0][1] == 100:
|
|
||||||
return picks[0][0], True, "Perfect Match"
|
|
||||||
elif picks[0][1] < 75:
|
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
|
||||||
elif dif > 5:
|
|
||||||
return picks[0][0], True, "Close Match"
|
|
||||||
else:
|
|
||||||
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
|
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
|
||||||
else:
|
|
||||||
if picks[0][1] > 90:
|
|
||||||
return picks[0][0], True, "Only Option Match"
|
|
||||||
else:
|
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
|
||||||
|
|
||||||
|
|
||||||
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
|
||||||
if "did you mean " in text:
|
|
||||||
for question in ("Didn't find something that closely matches",
|
|
||||||
"Too many close matches"):
|
|
||||||
if text.startswith(question):
|
|
||||||
name = get_text_between(text, "did you mean '",
|
|
||||||
"'? (")
|
|
||||||
return f"!{command} {name}"
|
|
||||||
elif text.startswith("Missing: "):
|
|
||||||
return text.replace("Missing: ", "!hint_location ")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
|
|||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
open(path, 'w').close()
|
open(path, 'w').close()
|
||||||
# Announcing commander unlocks
|
# Announcing commander unlocks
|
||||||
item_name = self.item_names.lookup_in_game(network_item.item)
|
item_name = self.item_names[network_item.item]
|
||||||
if item_name in faction_table.keys():
|
if item_name in faction_table.keys():
|
||||||
for commander in faction_table[item_name]:
|
for commander in faction_table[item_name]:
|
||||||
logger.info(f"{commander.name} has been unlocked!")
|
logger.info(f"{commander.name} has been unlocked!")
|
||||||
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
|
|||||||
open(print_path, 'w').close()
|
open(print_path, 'w').close()
|
||||||
with open(print_path, 'w') as f:
|
with open(print_path, 'w') as f:
|
||||||
f.write("Received " +
|
f.write("Received " +
|
||||||
self.item_names.lookup_in_game(network_item.item) +
|
self.item_names[network_item.item] +
|
||||||
" from " +
|
" from " +
|
||||||
self.player_names[network_item.player])
|
self.player_names[network_item.player])
|
||||||
f.close()
|
f.close()
|
||||||
@@ -342,7 +342,7 @@ class WargrooveContext(CommonContext):
|
|||||||
faction_items = 0
|
faction_items = 0
|
||||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||||
for network_item in self.items_received:
|
for network_item in self.items_received:
|
||||||
if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
|
if self.item_names[network_item.item] in faction_item_names:
|
||||||
faction_items += 1
|
faction_items += 1
|
||||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||||
# Must be an integer larger than 0
|
# Must be an integer larger than 0
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ ModuleUpdate.update()
|
|||||||
import Utils
|
import Utils
|
||||||
import settings
|
import settings
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||||
settings.no_gui = True
|
settings.no_gui = True
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
@@ -22,7 +19,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
|||||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||||
|
|
||||||
|
|
||||||
def get_app() -> "Flask":
|
def get_app():
|
||||||
from WebHostLib import register, cache, app as raw_app
|
from WebHostLib import register, cache, app as raw_app
|
||||||
from WebHostLib.models import db
|
from WebHostLib.models import db
|
||||||
|
|
||||||
@@ -58,7 +55,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
|
|
||||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||||
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, game)
|
target_path = os.path.join(base_target_path, game)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from uuid import UUID
|
|||||||
from flask import Blueprint, abort, url_for
|
from flask import Blueprint, abort, url_for
|
||||||
|
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
|
from .. import cache
|
||||||
from ..models import Room, Seed
|
from ..models import Room, Seed
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
@@ -48,4 +49,30 @@ def room_info(room: UUID):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
from . import generate, user, datapackage # trigger registration
|
@api_endpoints.route('/datapackage')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackage():
|
||||||
|
from worlds import network_data_package
|
||||||
|
return network_data_package
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route('/datapackage_version')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackage_versions():
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
|
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||||
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route('/datapackage_checksum')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackage_checksums():
|
||||||
|
from worlds import network_data_package
|
||||||
|
version_package = {
|
||||||
|
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||||
|
}
|
||||||
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
|
from . import generate, user # trigger registration
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
from flask import abort
|
|
||||||
|
|
||||||
from Utils import restricted_loads
|
|
||||||
from WebHostLib import cache
|
|
||||||
from WebHostLib.models import GameDataPackage
|
|
||||||
from . import api_endpoints
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/datapackage')
|
|
||||||
@cache.cached()
|
|
||||||
def get_datapackage():
|
|
||||||
from worlds import network_data_package
|
|
||||||
return network_data_package
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/datapackage/<string:checksum>')
|
|
||||||
@cache.memoize(timeout=3600)
|
|
||||||
def get_datapackage_by_checksum(checksum: str):
|
|
||||||
package = GameDataPackage.get(checksum=checksum)
|
|
||||||
if package:
|
|
||||||
return restricted_loads(package.data)
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/datapackage_checksum')
|
|
||||||
@cache.cached()
|
|
||||||
def get_datapackage_checksums():
|
|
||||||
from worlds import network_data_package
|
|
||||||
version_package = {
|
|
||||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
|
||||||
}
|
|
||||||
return version_package
|
|
||||||
@@ -168,28 +168,17 @@ def get_random_port():
|
|||||||
def get_static_server_data() -> dict:
|
def get_static_server_data() -> dict:
|
||||||
import worlds
|
import worlds
|
||||||
data = {
|
data = {
|
||||||
"non_hintable_names": {
|
"non_hintable_names": {},
|
||||||
world_name: world.hint_blacklist
|
"gamespackage": worlds.network_data_package["games"],
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||||
},
|
worlds.AutoWorldRegister.world_types.items()},
|
||||||
"gamespackage": {
|
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||||
world_name: {
|
worlds.AutoWorldRegister.world_types.items()},
|
||||||
key: value
|
|
||||||
for key, value in game_package.items()
|
|
||||||
if key not in ("item_name_groups", "location_name_groups")
|
|
||||||
}
|
|
||||||
for world_name, game_package in worlds.network_data_package["games"].items()
|
|
||||||
},
|
|
||||||
"item_name_groups": {
|
|
||||||
world_name: world.item_name_groups
|
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
|
||||||
},
|
|
||||||
"location_name_groups": {
|
|
||||||
world_name: world.location_name_groups
|
|
||||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
|
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -277,15 +266,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
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:
|
||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
if ctx.saving:
|
|
||||||
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
|
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
await ctx.shutdown_task
|
await ctx.shutdown_task
|
||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
if ctx.saving:
|
if ctx.saving:
|
||||||
ctx._save()
|
ctx._save()
|
||||||
setattr(asyncio.current_task(), "save", None)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
@@ -295,12 +281,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
else:
|
else:
|
||||||
if ctx.saving:
|
if ctx.saving:
|
||||||
ctx._save()
|
ctx._save()
|
||||||
setattr(asyncio.current_task(), "save", None)
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
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
|
|
||||||
# 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)
|
||||||
@@ -312,32 +294,13 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
rooms_shutting_down.put(room_id)
|
rooms_shutting_down.put(room_id)
|
||||||
|
|
||||||
class Starter(threading.Thread):
|
class Starter(threading.Thread):
|
||||||
_tasks: typing.List[asyncio.Future]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._tasks = []
|
|
||||||
|
|
||||||
def _done(self, task: asyncio.Future):
|
|
||||||
self._tasks.remove(task)
|
|
||||||
task.result()
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while 1:
|
while 1:
|
||||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||||
self._tasks.append(task)
|
|
||||||
task.add_done_callback(self._done)
|
|
||||||
logging.info(f"Starting room {next_room} on {name}.")
|
logging.info(f"Starting room {next_room} on {name}.")
|
||||||
|
|
||||||
starter = Starter()
|
starter = Starter()
|
||||||
starter.daemon = True
|
starter.daemon = True
|
||||||
starter.start()
|
starter.start()
|
||||||
try:
|
loop.run_forever()
|
||||||
loop.run_forever()
|
|
||||||
finally:
|
|
||||||
# save all tasks that want to be saved during shutdown
|
|
||||||
for task in asyncio.all_tasks(loop):
|
|
||||||
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
|
|
||||||
if save:
|
|
||||||
save()
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from typing import Dict, Iterator, List, Tuple, Union
|
from typing import List, Dict, Union
|
||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
@@ -97,36 +97,25 @@ def new_room(seed: UUID):
|
|||||||
return redirect(url_for("host_room", room=room.id))
|
return redirect(url_for("host_room", room=room.id))
|
||||||
|
|
||||||
|
|
||||||
def _read_log(path: str, offset: int = 0) -> Iterator[bytes]:
|
def _read_log(path: str):
|
||||||
with open(path, "rb") as log:
|
if os.path.exists(path):
|
||||||
marker = log.read(3) # skip optional BOM
|
with open(path, encoding="utf-8-sig") as log:
|
||||||
if marker != b'\xEF\xBB\xBF':
|
yield from log
|
||||||
log.seek(0, os.SEEK_SET)
|
else:
|
||||||
log.seek(offset, os.SEEK_CUR)
|
yield f"Logfile {path} does not exist. " \
|
||||||
yield from log
|
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||||
|
|
||||||
|
|
||||||
@app.route('/log/<suuid:room>')
|
@app.route('/log/<suuid:room>')
|
||||||
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
def display_log(room: UUID):
|
||||||
room = Room.get(id=room)
|
room = Room.get(id=room)
|
||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
if room.owner == session["_id"]:
|
if room.owner == session["_id"]:
|
||||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||||
try:
|
if os.path.exists(file_path):
|
||||||
range_header = request.headers.get("Range")
|
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||||
if range_header:
|
return "Log File does not exist."
|
||||||
range_type, range_values = range_header.split('=')
|
|
||||||
start, end = map(str.strip, range_values.split('-', 1))
|
|
||||||
if range_type != "bytes" or end != "":
|
|
||||||
return "Unsupported range", 500
|
|
||||||
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
|
|
||||||
return Response(_read_log(file_path, int(start)), mimetype="text/plain", status=206)
|
|
||||||
return Response(_read_log(file_path), mimetype="text/plain")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return Response(f"Logfile {file_path} does not exist. "
|
|
||||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
|
|
||||||
mimetype="text/plain")
|
|
||||||
|
|
||||||
return "Access Denied", 403
|
return "Access Denied", 403
|
||||||
|
|
||||||
@@ -150,21 +139,7 @@ def host_room(room: UUID):
|
|||||||
with db_session:
|
with db_session:
|
||||||
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
|
||||||
|
|
||||||
def get_log(max_size: int = 1024000) -> str:
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||||
try:
|
|
||||||
raw_size = 0
|
|
||||||
fragments: List[str] = []
|
|
||||||
for block in _read_log(os.path.join("logs", str(room.id) + ".txt")):
|
|
||||||
if raw_size + len(block) > max_size:
|
|
||||||
fragments.append("…")
|
|
||||||
break
|
|
||||||
raw_size += len(block)
|
|
||||||
fragments.append(block.decode("utf-8"))
|
|
||||||
return "".join(fragments)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
from docutils.core import publish_parts
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from flask import redirect, render_template, request, Response
|
||||||
@@ -67,22 +66,6 @@ def filter_dedent(text: str) -> str:
|
|||||||
return dedent(text).strip("\n ")
|
return dedent(text).strip("\n ")
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter("rst_to_html")
|
|
||||||
def filter_rst_to_html(text: str) -> str:
|
|
||||||
"""Converts reStructuredText (such as a Python docstring) to HTML."""
|
|
||||||
if text.startswith(" ") or text.startswith("\t"):
|
|
||||||
text = dedent(text)
|
|
||||||
elif "\n" in text:
|
|
||||||
lines = text.splitlines()
|
|
||||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
|
||||||
|
|
||||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
|
||||||
'raw_enable': False,
|
|
||||||
'file_insertion_enabled': False,
|
|
||||||
'output_encoding': 'unicode'
|
|
||||||
})['body']
|
|
||||||
|
|
||||||
|
|
||||||
@app.template_test("ordered")
|
@app.template_test("ordered")
|
||||||
def test_ordered(obj):
|
def test_ordered(obj):
|
||||||
return isinstance(obj, collections.abc.Sequence)
|
return isinstance(obj, collections.abc.Sequence)
|
||||||
@@ -93,34 +76,6 @@ def test_ordered(obj):
|
|||||||
def option_presets(game: str) -> Response:
|
def option_presets(game: str) -> Response:
|
||||||
world = AutoWorldRegister.world_types[game]
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
|
||||||
presets = {}
|
|
||||||
for preset_name, preset in world.web.options_presets.items():
|
|
||||||
presets[preset_name] = {}
|
|
||||||
for preset_option_name, preset_option in preset.items():
|
|
||||||
if preset_option == "random":
|
|
||||||
presets[preset_name][preset_option_name] = preset_option
|
|
||||||
continue
|
|
||||||
|
|
||||||
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
|
|
||||||
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
|
|
||||||
assert preset_option in option.special_range_names, \
|
|
||||||
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
|
|
||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
|
||||||
elif isinstance(preset_option, str):
|
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
|
||||||
assert option.name_lookup[option.value] == preset_option, \
|
|
||||||
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
|
|
||||||
f"Values must not be resolved to a different option via option.from_text (or an alias)."
|
|
||||||
# Use the name of the option
|
|
||||||
presets[preset_name][preset_option_name] = option.current_key
|
|
||||||
else:
|
|
||||||
# Use the name of the option
|
|
||||||
presets[preset_name][preset_option_name] = option.current_key
|
|
||||||
|
|
||||||
class SetEncoder(json.JSONEncoder):
|
class SetEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
from collections.abc import Set
|
from collections.abc import Set
|
||||||
@@ -128,7 +83,7 @@ def option_presets(game: str) -> Response:
|
|||||||
return list(obj)
|
return list(obj)
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
json_data = json.dumps(presets, cls=SetEncoder)
|
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
|
||||||
response = Response(json_data)
|
response = Response(json_data)
|
||||||
response.headers["Content-Type"] = "application/json"
|
response.headers["Content-Type"] = "application/json"
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
flask>=3.0.3
|
flask>=3.0.0
|
||||||
werkzeug>=3.0.3
|
|
||||||
pony>=0.7.17
|
pony>=0.7.17
|
||||||
waitress>=3.0.0
|
waitress>=2.1.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.1.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress>=1.14
|
||||||
Flask-Limiter>=3.7.0
|
Flask-Limiter>=3.5.0
|
||||||
bokeh>=3.1.1; python_version <= '3.8'
|
bokeh>=3.1.1; python_version <= '3.8'
|
||||||
bokeh>=3.4.1; python_version >= '3.9'
|
bokeh>=3.3.2; python_version >= '3.9'
|
||||||
markupsafe>=2.1.5
|
markupsafe>=2.1.3
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ from . import cache
|
|||||||
def robots():
|
def robots():
|
||||||
# If this host is not official, do not allow search engine crawling
|
# If this host is not official, do not allow search engine crawling
|
||||||
if not app.config["ASSET_RIGHTS"]:
|
if not app.config["ASSET_RIGHTS"]:
|
||||||
# filename changed in case the path is intercepted and served by an outside service
|
return app.send_static_file('robots.txt')
|
||||||
return app.send_static_file('robots_file.txt')
|
|
||||||
|
|
||||||
# Send 404 if the host has affirmed this to be the official WebHost
|
# Send 404 if the host has affirmed this to be the official WebHost
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
const secondsToHours = (seconds) => {
|
const secondsToHours = (seconds) => {
|
||||||
let hours = Math.floor(seconds / 3600);
|
let hours = Math.floor(seconds / 3600);
|
||||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
||||||
return `${hours}:${minutes}`;
|
return `${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
|
|||||||
info: false,
|
info: false,
|
||||||
dom: "t",
|
dom: "t",
|
||||||
stateSave: true,
|
stateSave: true,
|
||||||
stateSaveCallback: function (settings, data) {
|
stateSaveCallback: function(settings, data) {
|
||||||
delete data.search;
|
delete data.search;
|
||||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||||
},
|
},
|
||||||
stateLoadCallback: function (settings) {
|
stateLoadCallback: function(settings) {
|
||||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||||
},
|
},
|
||||||
footerCallback: function (tfoot, data, start, end, display) {
|
footerCallback: function(tfoot, data, start, end, display) {
|
||||||
if (tfoot) {
|
if (tfoot) {
|
||||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
||||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
||||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
@@ -123,64 +123,49 @@ window.addEventListener('load', () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
|
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||||
console.log("Target second of refresh: " + target_second);
|
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||||
|
|
||||||
function getSleepTimeSeconds() {
|
function getSleepTimeSeconds(){
|
||||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||||
return sleepSeconds || 60;
|
return sleepSeconds || 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
let update_on_view = false;
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (document.hidden) {
|
const target = $("<div></div>");
|
||||||
console.log("Document reporting as not visible, not updating Tracker...");
|
console.log("Updating Tracker...");
|
||||||
update_on_view = true;
|
target.load(location.href, function (response, status) {
|
||||||
} else {
|
if (status === "success") {
|
||||||
update_on_view = false;
|
target.find(".table").each(function (i, new_table) {
|
||||||
const target = $("<div></div>");
|
const new_trs = $(new_table).find("tbody>tr");
|
||||||
console.log("Updating Tracker...");
|
const footer_tr = $(new_table).find("tfoot>tr");
|
||||||
target.load(location.href, function (response, status) {
|
const old_table = tables.eq(i);
|
||||||
if (status === "success") {
|
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||||
target.find(".table").each(function (i, new_table) {
|
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||||
const new_trs = $(new_table).find("tbody>tr");
|
old_table.clear();
|
||||||
const footer_tr = $(new_table).find("tfoot>tr");
|
if (footer_tr.length) {
|
||||||
const old_table = tables.eq(i);
|
$(old_table.table).find("tfoot").html(footer_tr);
|
||||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
}
|
||||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
old_table.rows.add(new_trs);
|
||||||
old_table.clear();
|
old_table.draw();
|
||||||
if (footer_tr.length) {
|
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||||
$(old_table.table).find("tfoot").html(footer_tr);
|
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||||
}
|
});
|
||||||
old_table.rows.add(new_trs);
|
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||||
old_table.draw();
|
} else {
|
||||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
console.log(response);
|
||||||
});
|
}
|
||||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
})
|
||||||
} else {
|
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
|
||||||
}
|
}
|
||||||
let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
|
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
adjustTableHeight();
|
adjustTableHeight();
|
||||||
tables.draw();
|
tables.draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('visibilitychange', () => {
|
|
||||||
if (!document.hidden && update_on_view) {
|
|
||||||
console.log("Page became visible, tracker should be refreshed.");
|
|
||||||
clearTimeout(updater);
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
adjustTableHeight();
|
adjustTableHeight();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ html {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: #eeffeb;
|
color: #eeffeb;
|
||||||
word-break: break-word;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
#player-options #player-options-header h1 {
|
#player-options #player-options-header h1 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ html{
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: #eeffeb;
|
color: #eeffeb;
|
||||||
word-break: break-word;
|
word-break: break-all;
|
||||||
|
|
||||||
#player-options-header{
|
#player-options-header{
|
||||||
h1{
|
h1{
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* Base styles for the element that has a tooltip */
|
/* Base styles for the element that has a tooltip */
|
||||||
[data-tooltip], .tooltip-container {
|
[data-tooltip], .tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles for the entire tooltip */
|
/* Base styles for the entire tooltip */
|
||||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -39,15 +39,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
|
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
|
||||||
.tooltip-container:hover .tooltip {
|
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Directional arrow styles */
|
/** Directional arrow styles */
|
||||||
[data-tooltip]:before, .tooltip-container:before {
|
.tooltip:before, [data-tooltip]:before {
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
border: 6px solid transparent;
|
border: 6px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -55,7 +54,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Content styles */
|
/** Content styles */
|
||||||
[data-tooltip]:after, .tooltip {
|
.tooltip:after, [data-tooltip]:after {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -64,26 +63,24 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
background-color: hsla(0, 0%, 20%, 0.9);
|
background-color: hsla(0, 0%, 20%, 0.9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
|
white-space: pre-wrap;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:after {
|
[data-tooltip]:before, [data-tooltip]:after{
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
|
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after,
|
||||||
|
.tooltip-top:before, .tooltip-top:after {
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:before, .tooltip-container:before {
|
[data-tooltip]:before, .tooltip:before, .tooltip-top:before {
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
margin-bottom: -12px;
|
margin-bottom: -12px;
|
||||||
border-top-color: #000;
|
border-top-color: #000;
|
||||||
@@ -91,19 +88,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Horizontally align tooltips on the top and bottom */
|
/** Horizontally align tooltips on the top and bottom */
|
||||||
[data-tooltip]:after, .tooltip {
|
[data-tooltip]:after, .tooltip:after, .tooltip-top:after {
|
||||||
margin-left: -80px;
|
margin-left: -80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
|
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after,
|
||||||
.tooltip-container:hover .tooltip {
|
.tooltip-top:hover:before, .tooltip-top:hover:after {
|
||||||
-webkit-transform: translateY(-12px);
|
-webkit-transform: translateY(-12px);
|
||||||
-moz-transform: translateY(-12px);
|
-moz-transform: translateY(-12px);
|
||||||
transform: translateY(-12px);
|
transform: translateY(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tooltips on the left */
|
/** Tooltips on the left */
|
||||||
.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
|
.tooltip-left:before, .tooltip-left:after {
|
||||||
right: 100%;
|
right: 100%;
|
||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
left: auto;
|
left: auto;
|
||||||
@@ -118,14 +115,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
border-left-color: hsla(0, 0%, 20%, 0.9);
|
border-left-color: hsla(0, 0%, 20%, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
|
.tooltip-left:hover:before, .tooltip-left:hover:after {
|
||||||
-webkit-transform: translateX(-12px);
|
-webkit-transform: translateX(-12px);
|
||||||
-moz-transform: translateX(-12px);
|
-moz-transform: translateX(-12px);
|
||||||
transform: translateX(-12px);
|
transform: translateX(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tooltips on the bottom */
|
/** Tooltips on the bottom */
|
||||||
.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
|
.tooltip-bottom:before, .tooltip-bottom:after {
|
||||||
top: 100%;
|
top: 100%;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -139,15 +136,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
border-bottom-color: hsla(0, 0%, 20%, 0.9);
|
border-bottom-color: hsla(0, 0%, 20%, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
|
.tooltip-bottom:hover:before, .tooltip-bottom:hover:after {
|
||||||
.tooltip-bottom:hover .tooltip {
|
|
||||||
-webkit-transform: translateY(12px);
|
-webkit-transform: translateY(12px);
|
||||||
-moz-transform: translateY(12px);
|
-moz-transform: translateY(12px);
|
||||||
transform: translateY(12px);
|
transform: translateY(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tooltips on the right */
|
/** Tooltips on the right */
|
||||||
.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
|
.tooltip-right:before, .tooltip-right:after {
|
||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
@@ -160,8 +156,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
border-right-color: hsla(0, 0%, 20%, 0.9);
|
border-right-color: hsla(0, 0%, 20%, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
|
.tooltip-right:hover:before, .tooltip-right:hover:after {
|
||||||
.tooltip-right:hover .tooltip {
|
|
||||||
-webkit-transform: translateX(12px);
|
-webkit-transform: translateX(12px);
|
||||||
-moz-transform: translateX(12px);
|
-moz-transform: translateX(12px);
|
||||||
transform: translateX(12px);
|
transform: translateX(12px);
|
||||||
@@ -173,16 +168,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Center content vertically for tooltips ont he left and right */
|
/** Center content vertically for tooltips ont he left and right */
|
||||||
[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
|
.tooltip-left:after, .tooltip-right:after {
|
||||||
.tooltip-left .tooltip, .tooltip-right .tooltip {
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-bottom: -16px;
|
margin-bottom: -16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip ul, .tooltip ol {
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
<br />
|
<br />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if room.tracker %}
|
{% if room.tracker %}
|
||||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
|
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||||
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
|
|
||||||
<br />
|
<br />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||||
@@ -44,7 +43,7 @@
|
|||||||
{{ macros.list_patches_room(room) }}
|
{{ macros.list_patches_room(room) }}
|
||||||
{% if room.owner == session["_id"] %}
|
{% if room.owner == session["_id"] %}
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
<form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
|
<form method=post style="flex-grow: 1; margin-right: 1em;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="cmd"></label>
|
<label for="cmd"></label>
|
||||||
<input class="form-control" type="text" id="cmd" name="cmd"
|
<input class="form-control" type="text" id="cmd" name="cmd"
|
||||||
@@ -55,89 +54,24 @@
|
|||||||
Open Log File...
|
Open Log File...
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% set log = get_log() -%}
|
<div id="logger"></div>
|
||||||
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
<script type="application/ecmascript">
|
||||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
let xmlhttp = new XMLHttpRequest();
|
||||||
<script>
|
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
|
||||||
let bytesReceived = {{ log_len }};
|
|
||||||
let updateLogTimeout;
|
|
||||||
let awaitingCommandResponse = false;
|
|
||||||
let logger = document.getElementById("logger");
|
|
||||||
|
|
||||||
function scrollToBottom(el) {
|
xmlhttp.onreadystatechange = function () {
|
||||||
let bot = el.scrollHeight - el.clientHeight;
|
if (this.readyState === 4 && this.status === 200) {
|
||||||
el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
|
document.getElementById("logger").innerText = this.responseText;
|
||||||
if (bot - el.scrollTop >= 1) {
|
|
||||||
window.clearTimeout(el.scrollTimer);
|
|
||||||
el.scrollTimer = window.setTimeout(() => {
|
|
||||||
scrollToBottom(el)
|
|
||||||
}, 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateLog() {
|
|
||||||
try {
|
|
||||||
let res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Range': `bytes=${bytesReceived}-`,
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
if (res.ok) {
|
|
||||||
let text = await res.text();
|
|
||||||
if (text.length > 0) {
|
|
||||||
awaitingCommandResponse = false;
|
|
||||||
if (bytesReceived === 0 || res.status !== 206) {
|
|
||||||
logger.innerHTML = '';
|
|
||||||
}
|
|
||||||
if (res.status !== 206) {
|
|
||||||
bytesReceived = 0;
|
|
||||||
} else {
|
|
||||||
bytesReceived += new Blob([text]).size;
|
|
||||||
}
|
|
||||||
if (logger.innerHTML.endsWith('…')) {
|
|
||||||
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
|
|
||||||
}
|
|
||||||
logger.appendChild(document.createTextNode(text));
|
|
||||||
scrollToBottom(logger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
window.clearTimeout(updateLogTimeout);
|
|
||||||
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postForm(ev) {
|
function request_new() {
|
||||||
/** @type {HTMLInputElement} */
|
xmlhttp.open("GET", url, true);
|
||||||
let cmd = document.getElementById("cmd");
|
xmlhttp.send();
|
||||||
if (cmd.value === "") {
|
|
||||||
ev.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
/** @type {HTMLFormElement} */
|
|
||||||
let form = document.getElementById("command-form");
|
|
||||||
let req = fetch(form.action || window.location.href, {
|
|
||||||
method: form.method,
|
|
||||||
body: new FormData(form),
|
|
||||||
redirect: "manual",
|
|
||||||
});
|
|
||||||
ev.preventDefault(); // has to happen before first await
|
|
||||||
form.reset();
|
|
||||||
let res = await req;
|
|
||||||
if (res.ok || res.type === 'opaqueredirect') {
|
|
||||||
awaitingCommandResponse = true;
|
|
||||||
window.clearTimeout(updateLogTimeout);
|
|
||||||
updateLogTimeout = window.setTimeout(updateLog, 100);
|
|
||||||
} else {
|
|
||||||
window.alert(res.statusText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("command-form").addEventListener("submit", postForm);
|
window.setTimeout(request_new, 1000);
|
||||||
updateLogTimeout = window.setTimeout(updateLog, 1000);
|
window.setInterval(request_new, 10000);
|
||||||
logger.scrollTop = logger.scrollHeight;
|
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
{% extends "tablepage.html" %}
|
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
|
||||||
<title>Multiworld Sphere Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
|
|
||||||
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% include "header/dirtHeader.html" %}
|
|
||||||
|
|
||||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
|
|
||||||
<div id="tracker-header-bar">
|
|
||||||
<input placeholder="Search" id="search" />
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
{% if tracker_data.get_spheres() %}
|
|
||||||
This tracker lists already found locations by their logical access sphere.
|
|
||||||
It ignores items that cannot be sent
|
|
||||||
and will therefore differ from the sphere numbers in the spoiler playthrough.
|
|
||||||
This tracker will automatically update itself periodically.
|
|
||||||
{% else %}
|
|
||||||
This Multiworld has no Sphere data, likely due to being too old, cannot display data.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tables-container">
|
|
||||||
{%- for team, players in tracker_data.get_all_players().items() %}
|
|
||||||
<div class="table-wrapper">
|
|
||||||
<table id="checks-table" class="table non-unique-item-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Sphere</th>
|
|
||||||
{#- Mimicking hint table header for familiarity. #}
|
|
||||||
<th>Finder</th>
|
|
||||||
<th>Receiver</th>
|
|
||||||
<th>Item</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Game</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{%- for sphere in tracker_data.get_spheres() %}
|
|
||||||
{%- set current_sphere = loop.index %}
|
|
||||||
{%- for player, sphere_location_ids in sphere.items() %}
|
|
||||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
|
||||||
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
|
||||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
|
||||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
|
||||||
<tr>
|
|
||||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
|
||||||
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
|
||||||
<td>{{ current_sphere }}</td>
|
|
||||||
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
|
||||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
|
||||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
|
||||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
|
||||||
<td>{{ finder_game }}</td>
|
|
||||||
</tr>
|
|
||||||
{%- endfor %}
|
|
||||||
|
|
||||||
{%- endfor %}
|
|
||||||
{%- endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
{% include "header/dirtHeader.html" %}
|
{% include "header/dirtHeader.html" %}
|
||||||
{% include "multitrackerNavigation.html" %}
|
{% include "multitrackerNavigation.html" %}
|
||||||
|
|
||||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}" data-second="{{ saving_second }}">
|
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
|
||||||
<div id="tracker-header-bar">
|
<div id="tracker-header-bar">
|
||||||
<input placeholder="Search" id="search" />
|
<input placeholder="Search" id="search" />
|
||||||
|
|
||||||
|
|||||||
180
WebHostLib/templates/ootTracker.html
Normal file
180
WebHostLib/templates/ootTracker.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/ootTracker.css') }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<table id="inventory-table">
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
|
||||||
|
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
|
||||||
|
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
|
||||||
|
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
|
||||||
|
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
|
||||||
|
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
|
||||||
|
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
|
||||||
|
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
|
||||||
|
<div class="item-count">{{ hookshot_length }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
|
||||||
|
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
|
||||||
|
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
|
||||||
|
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
|
||||||
|
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
|
||||||
|
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
|
||||||
|
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
|
||||||
|
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
|
||||||
|
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
|
||||||
|
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
|
||||||
|
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
|
||||||
|
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
|
||||||
|
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
|
||||||
|
<div class="item-count">{{ wallet_size }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
|
||||||
|
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
|
||||||
|
<div class="item-count">Zelda</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
|
||||||
|
<div class="item-count">Epona</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
|
||||||
|
<div class="item-count">Saria</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
|
||||||
|
<div class="item-count">Sun</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
|
||||||
|
<div class="item-count">Time</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
|
||||||
|
<div class="item-count">Storms</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
|
||||||
|
<div class="item-count">{{ token_count }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
|
||||||
|
<div class="item-count">Min</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
|
||||||
|
<div class="item-count">Bol</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
|
||||||
|
<div class="item-count">Ser</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
|
||||||
|
<div class="item-count">Req</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
|
||||||
|
<div class="item-count">Noc</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
|
||||||
|
<div class="item-count">Pre</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="counted-item">
|
||||||
|
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
|
||||||
|
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table id="location-table">
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
|
||||||
|
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
|
||||||
|
<td class="right-align">Items</td>
|
||||||
|
</tr>
|
||||||
|
{% for area in checks_done %}
|
||||||
|
<tr class="location-category" id="{{area}}-header">
|
||||||
|
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
||||||
|
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
|
||||||
|
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</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></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -57,9 +57,9 @@
|
|||||||
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||||
{% for key, val in option.special_range_names.items() %}
|
{% for key, val in option.special_range_names.items() %}
|
||||||
{% if option.default == val %}
|
{% if option.default == val %}
|
||||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option>
|
||||||
{% else %}
|
{% else %}
|
||||||
<option value="{{ val }}">{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
<option value="{{ val }}">{{ key }} ({{ val }})</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<option value="custom" hidden>Custom</option>
|
<option value="custom" hidden>Custom</option>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option) %}
|
{% macro ItemDict(option_name, option, world) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option, world) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<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 %}
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option, world) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<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 %}
|
||||||
@@ -196,18 +196,7 @@
|
|||||||
{% macro OptionTitle(option_name, option) %}
|
{% macro OptionTitle(option_name, option) %}
|
||||||
<label for="{{ option_name }}">
|
<label for="{{ option_name }}">
|
||||||
{{ option.display_name|default(option_name) }}:
|
{{ option.display_name|default(option_name) }}:
|
||||||
<span
|
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span>
|
||||||
class="interactive tooltip-container"
|
|
||||||
{% if not (option.rich_text_doc | default(world.web.rich_text_options_doc, true)) %}
|
|
||||||
data-tooltip="{{(option.__doc__ | default("Please document me!"))|replace('\n ', '\n')|escape|trim}}"
|
|
||||||
{% endif %}>
|
|
||||||
(?)
|
|
||||||
{% if option.rich_text_doc | default(world.web.rich_text_options_doc, true) %}
|
|
||||||
<div class="tooltip">
|
|
||||||
{{ option.__doc__ | default("**Please document me!**") | rst_to_html | safe }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import 'playerOptions/macros.html' as inputs with context %}
|
{% import 'playerOptions/macros.html' as inputs %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>{{ world_name }} Options</title>
|
<title>{{ world_name }} Options</title>
|
||||||
@@ -94,16 +94,16 @@
|
|||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
{{ inputs.ItemDict(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||||
{{ inputs.LocationSet(option_name, option) }}
|
{{ inputs.LocationSet(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||||
{{ inputs.ItemSet(option_name, option) }}
|
{{ inputs.ItemSet(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||||
{{ inputs.OptionSet(option_name, option) }}
|
{{ inputs.OptionSet(option_name, option) }}
|
||||||
@@ -134,16 +134,16 @@
|
|||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
{{ inputs.ItemDict(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
|
||||||
{{ inputs.LocationSet(option_name, option) }}
|
{{ inputs.LocationSet(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
|
||||||
{{ inputs.ItemSet(option_name, option) }}
|
{{ inputs.ItemSet(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
|
||||||
{{ inputs.OptionSet(option_name, option) }}
|
{{ inputs.OptionSet(option_name, option) }}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% macro Toggle(option_name, option) %}
|
{% macro Toggle(option_name, option) %}
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
|
{{ RangeRow(option_name, option, "No", "false") }}
|
||||||
{{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
|
{{ RangeRow(option_name, option, "Yes", "true") }}
|
||||||
{{ RandomRow(option_name, option) }}
|
{{ RandomRows(option_name, option) }}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -18,14 +18,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for id, name in option.name_lookup.items() %}
|
{% for id, name in option.name_lookup.items() %}
|
||||||
{% if name != 'random' %}
|
{% if name != 'random' %}
|
||||||
{% if option.default != 'random' %}
|
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
|
|
||||||
{% else %}
|
|
||||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ RandomRow(option_name, option) }}
|
{{ RandomRows(option_name, option) }}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -38,16 +34,16 @@
|
|||||||
Normal range: {{ option.range_start }} - {{ option.range_end }}
|
Normal range: {{ option.range_start }} - {{ option.range_end }}
|
||||||
{% if option.special_range_names %}
|
{% if option.special_range_names %}
|
||||||
<br /><br />
|
<br /><br />
|
||||||
The following values have special meanings, and may fall outside the normal range.
|
The following values has special meaning, and may fall outside the normal range.
|
||||||
<ul>
|
<ul>
|
||||||
{% for name, value in option.special_range_names.items() %}
|
{% for name, value in option.special_range_names.items() %}
|
||||||
<li>{{ value }}: {{ name|replace("_", " ")|title }}</li>
|
<li>{{ value }}: {{ name }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="add-option-div">
|
<div class="add-option-div">
|
||||||
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
|
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
|
||||||
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="range-rows" data-option="{{ option_name }}">
|
<table class="range-rows" data-option="{{ option_name }}">
|
||||||
@@ -72,13 +68,11 @@
|
|||||||
This option allows custom values only. Please enter your desired values below.
|
This option allows custom values only. Please enter your desired values below.
|
||||||
<div class="custom-value-wrapper">
|
<div class="custom-value-wrapper">
|
||||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
<button data-option="{{ option_name }}">Add</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if option.default %}
|
<!-- This table to be filled by JS -->
|
||||||
{{ RangeRow(option_name, option, option.default, option.default) }}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,21 +83,17 @@
|
|||||||
Custom values are also allowed for this option. To create one, enter it into the input box below.
|
Custom values are also allowed for this option. To create one, enter it into the input box below.
|
||||||
<div class="custom-value-wrapper">
|
<div class="custom-value-wrapper">
|
||||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
<button data-option="{{ option_name }}">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for id, name in option.name_lookup.items() %}
|
{% for id, name in option.name_lookup.items() %}
|
||||||
{% if name != 'random' %}
|
{% if name != 'random' %}
|
||||||
{% if option.default != 'random' %}
|
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||||
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
|
|
||||||
{% else %}
|
|
||||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ RandomRow(option_name, option) }}
|
{{ RandomRows(option_name, option) }}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -122,7 +112,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
id="{{ option_name }}-{{ item_name }}-qty"
|
id="{{ option_name }}-{{ item_name }}-qty"
|
||||||
name="{{ option_name }}||{{ item_name }}"
|
name="{{ option_name }}||{{ item_name }}"
|
||||||
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
|
value="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -131,14 +121,13 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
<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|sort %}
|
||||||
<div class="list-entry">
|
<div class="list-entry">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="{{ option_name }}-{{ key }}"
|
id="{{ option_name }}-{{ key }}"
|
||||||
name="{{ option_name }}||{{ key }}"
|
name="{{ option_name }}||{{ key }}"
|
||||||
value="1"
|
value="1"
|
||||||
checked="{{ "checked" if key in option.default else "" }}"
|
|
||||||
/>
|
/>
|
||||||
<label for="{{ option_name }}-{{ key }}">
|
<label for="{{ option_name }}-{{ key }}">
|
||||||
{{ key }}
|
{{ key }}
|
||||||
@@ -194,7 +183,7 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
<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|sort %}
|
||||||
<div class="set-entry">
|
<div class="set-entry">
|
||||||
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
|
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
|
||||||
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
|
||||||
@@ -211,17 +200,13 @@
|
|||||||
</td>
|
</td>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomRow(option_name, option, extra_column=False) %}
|
|
||||||
{{ RangeRow(option_name, option, "Random", "random") }}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro RandomRows(option_name, option, extra_column=False) %}
|
{% macro RandomRows(option_name, option, extra_column=False) %}
|
||||||
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
|
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
|
||||||
{{ RangeRow(option_name, option, key, value) }}
|
{{ RangeRow(option_name, option, key, value) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
|
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
|
||||||
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
|
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
|
||||||
<td class="td-left">
|
<td class="td-left">
|
||||||
<label for="{{ option_name }}||{{ value }}">
|
<label for="{{ option_name }}||{{ value }}">
|
||||||
@@ -235,7 +220,7 @@
|
|||||||
name="{{ option_name }}||{{ value }}"
|
name="{{ option_name }}||{{ value }}"
|
||||||
min="0"
|
min="0"
|
||||||
max="50"
|
max="50"
|
||||||
{% if option.default == value or default_override == value %}
|
{% if option.default == value %}
|
||||||
value="25"
|
value="25"
|
||||||
{% else %}
|
{% else %}
|
||||||
value="0"
|
value="0"
|
||||||
@@ -244,7 +229,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="td-right">
|
<td class="td-right">
|
||||||
<span id="{{ option_name }}||{{ value }}-value">
|
<span id="{{ option_name }}||{{ value }}-value">
|
||||||
{% if option.default == value or default_override == value %}
|
{% if option.default == value %}
|
||||||
25
|
25
|
||||||
{% else %}
|
{% else %}
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import collections
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
from flask import render_template, make_response, Response, request
|
from flask import render_template
|
||||||
from werkzeug.exceptions import abort
|
from werkzeug.exceptions import abort
|
||||||
|
|
||||||
from MultiServer import Context, get_saving_second
|
from MultiServer import Context, get_saving_second
|
||||||
@@ -292,47 +291,47 @@ class TrackerData:
|
|||||||
|
|
||||||
return video_feeds
|
return video_feeds
|
||||||
|
|
||||||
@_cache_results
|
|
||||||
def get_spheres(self) -> List[List[int]]:
|
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||||
""" each sphere is { player: { location_id, ... } } """
|
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> str:
|
||||||
return self._multidata.get("spheres", [])
|
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
|
||||||
|
tracker_page = cache.get(key)
|
||||||
|
if tracker_page:
|
||||||
|
return tracker_page
|
||||||
|
|
||||||
|
timeout, tracker_page = get_timeout_and_tracker(tracker, tracked_team, tracked_player, generic)
|
||||||
|
cache.set(key, tracker_page, timeout)
|
||||||
|
return tracker_page
|
||||||
|
|
||||||
|
|
||||||
def _process_if_request_valid(incoming_request, room: Optional[Room]) -> Optional[Response]:
|
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
||||||
|
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> str:
|
||||||
|
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
|
||||||
|
@app.route("/tracker/<suuid:tracker>/<game>")
|
||||||
|
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
|
||||||
|
def get_multiworld_tracker(tracker: UUID, game: str):
|
||||||
|
# Room must exist.
|
||||||
|
room = Room.get(tracker=tracker)
|
||||||
if not room:
|
if not room:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if_modified = incoming_request.headers.get("If-Modified-Since", None)
|
tracker_data = TrackerData(room)
|
||||||
if if_modified:
|
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
|
||||||
if_modified = parsedate_to_datetime(if_modified)
|
if game not in _multiworld_trackers:
|
||||||
# if_modified has less precision than last_activity, so we bring them to same precision
|
return render_generic_multiworld_tracker(tracker_data, enabled_trackers)
|
||||||
if if_modified >= room.last_activity.replace(microsecond=0):
|
|
||||||
return make_response("", 304)
|
return _multiworld_trackers[game](tracker_data, enabled_trackers)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool) -> Tuple[int, str]:
|
||||||
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> Response:
|
|
||||||
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
|
|
||||||
response: Optional[Response] = cache.get(key)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Room must exist.
|
# Room must exist.
|
||||||
room = Room.get(tracker=tracker)
|
room = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
response = _process_if_request_valid(request, room)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
timeout, last_modified, tracker_page = get_timeout_and_player_tracker(room, tracked_team, tracked_player, generic)
|
|
||||||
response = make_response(tracker_page)
|
|
||||||
response.last_modified = last_modified
|
|
||||||
cache.set(key, response, timeout)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player: int, generic: bool)\
|
|
||||||
-> Tuple[int, datetime.datetime, str]:
|
|
||||||
tracker_data = TrackerData(room)
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
||||||
@@ -342,48 +341,7 @@ def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player
|
|||||||
else:
|
else:
|
||||||
tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player)
|
tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player)
|
||||||
|
|
||||||
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
|
return (tracker_data.get_room_saving_second() - datetime.datetime.now().second) % 60 or 60, tracker
|
||||||
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
|
|
||||||
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> Response:
|
|
||||||
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
|
|
||||||
@app.route("/tracker/<suuid:tracker>/<game>")
|
|
||||||
def get_multiworld_tracker(tracker: UUID, game: str) -> Response:
|
|
||||||
key = f"{tracker}_{game}"
|
|
||||||
response: Optional[Response] = cache.get(key)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Room must exist.
|
|
||||||
room = Room.get(tracker=tracker)
|
|
||||||
|
|
||||||
response = _process_if_request_valid(request, room)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
timeout, last_modified, tracker_page = get_timeout_and_multiworld_tracker(room, game)
|
|
||||||
response = make_response(tracker_page)
|
|
||||||
response.last_modified = last_modified
|
|
||||||
cache.set(key, response, timeout)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def get_timeout_and_multiworld_tracker(room: Room, game: str)\
|
|
||||||
-> Tuple[int, datetime.datetime, str]:
|
|
||||||
tracker_data = TrackerData(room)
|
|
||||||
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
|
|
||||||
if game in _multiworld_trackers:
|
|
||||||
tracker = _multiworld_trackers[game](tracker_data, enabled_trackers)
|
|
||||||
else:
|
|
||||||
tracker = render_generic_multiworld_tracker(tracker_data, enabled_trackers)
|
|
||||||
|
|
||||||
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
|
|
||||||
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
|
|
||||||
|
|
||||||
|
|
||||||
def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
|
def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
|
||||||
@@ -453,30 +411,9 @@ def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_tracker
|
|||||||
videos=tracker_data.get_room_videos(),
|
videos=tracker_data.get_room_videos(),
|
||||||
item_id_to_name=tracker_data.item_id_to_name,
|
item_id_to_name=tracker_data.item_id_to_name,
|
||||||
location_id_to_name=tracker_data.location_id_to_name,
|
location_id_to_name=tracker_data.location_id_to_name,
|
||||||
saving_second=tracker_data.get_room_saving_second(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_generic_multiworld_sphere_tracker(tracker_data: TrackerData) -> str:
|
|
||||||
return render_template(
|
|
||||||
"multispheretracker.html",
|
|
||||||
room=tracker_data.room,
|
|
||||||
tracker_data=tracker_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sphere_tracker/<suuid:tracker>")
|
|
||||||
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
|
|
||||||
def get_multiworld_sphere_tracker(tracker: UUID):
|
|
||||||
# Room must exist.
|
|
||||||
room = Room.get(tracker=tracker)
|
|
||||||
if not room:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
tracker_data = TrackerData(room)
|
|
||||||
return render_generic_multiworld_sphere_tracker(tracker_data)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to
|
# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to
|
||||||
# live in their respective world folders.
|
# live in their respective world folders.
|
||||||
|
|
||||||
@@ -1366,28 +1303,28 @@ if "Starcraft 2" in network_data_package["games"]:
|
|||||||
organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/"
|
organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/"
|
||||||
|
|
||||||
icons = {
|
icons = {
|
||||||
"Starting Minerals": github_icon_base_url + "blizzard/icon-mineral-nobg.png",
|
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
|
||||||
"Starting Vespene": github_icon_base_url + "blizzard/icon-gas-terran-nobg.png",
|
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
|
||||||
"Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png",
|
"Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png",
|
||||||
|
|
||||||
"Terran Infantry Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel1.png",
|
"Terran Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
|
||||||
"Terran Infantry Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel2.png",
|
"Terran Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
|
||||||
"Terran Infantry Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel3.png",
|
"Terran Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
|
||||||
"Terran Infantry Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel1.png",
|
"Terran Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
|
||||||
"Terran Infantry Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel2.png",
|
"Terran Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
|
||||||
"Terran Infantry Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel3.png",
|
"Terran Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
|
||||||
"Terran Vehicle Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel1.png",
|
"Terran Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
|
||||||
"Terran Vehicle Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel2.png",
|
"Terran Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
|
||||||
"Terran Vehicle Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel3.png",
|
"Terran Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
|
||||||
"Terran Vehicle Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel1.png",
|
"Terran Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
|
||||||
"Terran Vehicle Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel2.png",
|
"Terran Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
|
||||||
"Terran Vehicle Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel3.png",
|
"Terran Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
|
||||||
"Terran Ship Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel1.png",
|
"Terran Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
|
||||||
"Terran Ship Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel2.png",
|
"Terran Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
|
||||||
"Terran Ship Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel3.png",
|
"Terran Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
|
||||||
"Terran Ship Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel1.png",
|
"Terran Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
|
||||||
"Terran Ship Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel2.png",
|
"Terran Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
|
||||||
"Terran Ship Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel3.png",
|
"Terran Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
|
||||||
|
|
||||||
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
|
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
|
||||||
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
|
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
|
|||||||
|
|
||||||
|
|
||||||
def reconcile_shops(ctx: ZeldaContext):
|
def reconcile_shops(ctx: ZeldaContext):
|
||||||
checked_location_names = [ctx.location_names.lookup_in_game(location) for location in ctx.checked_locations]
|
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations]
|
||||||
shops = [location for location in checked_location_names if "Shop" in location]
|
shops = [location for location in checked_location_names if "Shop" in location]
|
||||||
left_slots = [shop for shop in shops if "Left" in shop]
|
left_slots = [shop for shop in shops if "Left" in shop]
|
||||||
middle_slots = [shop for shop in shops if "Middle" in shop]
|
middle_slots = [shop for shop in shops if "Middle" in shop]
|
||||||
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
|
|||||||
locations_checked = []
|
locations_checked = []
|
||||||
location = None
|
location = None
|
||||||
for location in ctx.missing_locations:
|
for location in ctx.missing_locations:
|
||||||
location_name = ctx.location_names.lookup_in_game(location)
|
location_name = ctx.location_names[location]
|
||||||
|
|
||||||
if location_name in Locations.overworld_locations and zone == "overworld":
|
if location_name in Locations.overworld_locations and zone == "overworld":
|
||||||
status = locations_array[Locations.major_location_offsets[location_name]]
|
status = locations_array[Locations.major_location_offsets[location_name]]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#cython: language_level=3
|
#cython: language_level=3
|
||||||
#distutils: language = c
|
#distutils: language = c++
|
||||||
#distutils: depends = intset.h
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Provides faster implementation of some core parts.
|
Provides faster implementation of some core parts.
|
||||||
@@ -14,6 +13,7 @@ from cpython cimport PyObject
|
|||||||
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
|
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
|
||||||
from cymem.cymem cimport Pool
|
from cymem.cymem cimport Pool
|
||||||
from libc.stdint cimport int64_t, uint32_t
|
from libc.stdint cimport int64_t, uint32_t
|
||||||
|
from libcpp.set cimport set as std_set
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
cdef extern from *:
|
cdef extern from *:
|
||||||
@@ -31,27 +31,6 @@ ctypedef int64_t ap_id_t
|
|||||||
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
|
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
|
||||||
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
|
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
|
||||||
|
|
||||||
# configure INTSET for player
|
|
||||||
cdef extern from *:
|
|
||||||
"""
|
|
||||||
#define INTSET_NAME ap_player_set
|
|
||||||
#define INTSET_TYPE uint32_t // has to match ap_player_t
|
|
||||||
"""
|
|
||||||
|
|
||||||
# create INTSET for player
|
|
||||||
cdef extern from "intset.h":
|
|
||||||
"""
|
|
||||||
#undef INTSET_NAME
|
|
||||||
#undef INTSET_TYPE
|
|
||||||
"""
|
|
||||||
ctypedef struct ap_player_set:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
|
|
||||||
void ap_player_set_free(ap_player_set* set) nogil
|
|
||||||
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
|
|
||||||
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
|
|
||||||
|
|
||||||
|
|
||||||
cdef struct LocationEntry:
|
cdef struct LocationEntry:
|
||||||
# layout is so that
|
# layout is so that
|
||||||
@@ -206,7 +185,7 @@ cdef class LocationStore:
|
|||||||
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
|
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
|
||||||
cdef ap_id_t item = seeked_item_id
|
cdef ap_id_t item = seeked_item_id
|
||||||
cdef ap_player_t receiver
|
cdef ap_player_t receiver
|
||||||
cdef ap_player_set* receivers
|
cdef std_set[ap_player_t] receivers
|
||||||
cdef size_t slot_count = len(slots)
|
cdef size_t slot_count = len(slots)
|
||||||
if slot_count == 1:
|
if slot_count == 1:
|
||||||
# specialized implementation for single slot
|
# specialized implementation for single slot
|
||||||
@@ -218,20 +197,13 @@ cdef class LocationStore:
|
|||||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||||
elif slot_count:
|
elif slot_count:
|
||||||
# generic implementation with lookup in set
|
# generic implementation with lookup in set
|
||||||
receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
|
for receiver in slots:
|
||||||
if not receivers:
|
receivers.insert(receiver)
|
||||||
raise MemoryError()
|
with nogil:
|
||||||
try:
|
for entry in self.entries[:self.entry_count]:
|
||||||
for receiver in slots:
|
if entry.item == item and receivers.count(entry.receiver):
|
||||||
if not ap_player_set_add(receivers, receiver):
|
with gil:
|
||||||
raise MemoryError()
|
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||||
with nogil:
|
|
||||||
for entry in self.entries[:self.entry_count]:
|
|
||||||
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
|
|
||||||
with gil:
|
|
||||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
|
||||||
finally:
|
|
||||||
ap_player_set_free(receivers)
|
|
||||||
|
|
||||||
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
|
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
|
||||||
cdef ap_player_t receiver = slot
|
cdef ap_player_t receiver = slot
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# This file is used when doing pyximport
|
# This file is required to get pyximport to work with C++.
|
||||||
import os
|
# Switching from std::set to a pure C implementation is still on the table to simplify everything.
|
||||||
|
|
||||||
def make_ext(modname, pyxfilename):
|
def make_ext(modname, pyxfilename):
|
||||||
from distutils.extension import Extension
|
from distutils.extension import Extension
|
||||||
return Extension(name=modname,
|
return Extension(name=modname,
|
||||||
sources=[pyxfilename],
|
sources=[pyxfilename],
|
||||||
depends=["intset.h"],
|
language='c++')
|
||||||
include_dirs=[os.getcwd()],
|
|
||||||
language="c")
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
plum: "AF99EF" # typically progression item
|
plum: "AF99EF" # typically progression item
|
||||||
salmon: "FA8072" # typically trap item
|
salmon: "FA8072" # typically trap item
|
||||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||||
orange: "FF7700" # Used for command echo
|
|
||||||
<Label>:
|
<Label>:
|
||||||
color: "FFFFFF"
|
color: "FFFFFF"
|
||||||
<TabbedPanel>:
|
<TabbedPanel>:
|
||||||
|
|||||||
@@ -68,21 +68,21 @@ requires:
|
|||||||
|
|
||||||
{%- elif option.options -%}
|
{%- elif option.options -%}
|
||||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
|
||||||
{%- if option.name_lookup[option.default] not in option.options %}
|
{%- if option.name_lookup[option.default] not in option.options %}
|
||||||
{{ yaml_dump(option.default) }}: 50
|
{{ option.default }}: 50
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
{%- elif option.default is string %}
|
{%- elif option.default is string %}
|
||||||
{{ yaml_dump(option.default) }}: 50
|
{{ option.default }}: 50
|
||||||
|
|
||||||
{%- elif option.default is iterable and option.default is not mapping %}
|
{%- elif option.default is iterable and option.default is not mapping %}
|
||||||
{{ option.default | list }}
|
{{ option.default | list }}
|
||||||
|
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{{ yaml_dump(option.default) | indent(4, first=false) }}
|
{{ yaml_dump(option.default) | trim | indent(4, first=false) }}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{{ "\n" }}
|
{{ "\n" }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|||||||
@@ -15,15 +15,15 @@
|
|||||||
# A Link to the Past
|
# A Link to the Past
|
||||||
/worlds/alttp/ @Berserker66
|
/worlds/alttp/ @Berserker66
|
||||||
|
|
||||||
# Sudoku (APSudoku)
|
|
||||||
/worlds/apsudoku/ @EmilyV99
|
|
||||||
|
|
||||||
# Aquaria
|
# Aquaria
|
||||||
/worlds/aquaria/ @tioui
|
/worlds/aquaria/ @tioui
|
||||||
|
|
||||||
# ArchipIDLE
|
# ArchipIDLE
|
||||||
/worlds/archipidle/ @LegendaryLinux
|
/worlds/archipidle/ @LegendaryLinux
|
||||||
|
|
||||||
|
# Sudoku (BK Sudoku)
|
||||||
|
/worlds/bk_sudoku/ @Jarno458
|
||||||
|
|
||||||
# Blasphemous
|
# Blasphemous
|
||||||
/worlds/blasphemous/ @TRPG0
|
/worlds/blasphemous/ @TRPG0
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
/worlds/heretic/ @Daivuk
|
/worlds/heretic/ @Daivuk
|
||||||
|
|
||||||
# Hollow Knight
|
# Hollow Knight
|
||||||
/worlds/hk/ @BadMagic100 @qwint
|
/worlds/hk/ @BadMagic100 @ThePhar
|
||||||
|
|
||||||
# Hylics 2
|
# Hylics 2
|
||||||
/worlds/hylics2/ @TRPG0
|
/worlds/hylics2/ @TRPG0
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
# Lingo
|
# Lingo
|
||||||
/worlds/lingo/ @hatkirby
|
/worlds/lingo/ @hatkirby
|
||||||
|
|
||||||
|
# Links Awakening DX
|
||||||
|
/worlds/ladx/ @zig-for
|
||||||
|
|
||||||
# Lufia II Ancient Cave
|
# Lufia II Ancient Cave
|
||||||
/worlds/lufia2ac/ @el-u
|
/worlds/lufia2ac/ @el-u
|
||||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||||
@@ -215,8 +218,6 @@
|
|||||||
# Final Fantasy (1)
|
# Final Fantasy (1)
|
||||||
# /worlds/ff1/
|
# /worlds/ff1/
|
||||||
|
|
||||||
# Links Awakening DX
|
|
||||||
# /worlds/ladx/
|
|
||||||
|
|
||||||
## Disabled Unmaintained Worlds
|
## Disabled Unmaintained Worlds
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## (Server -> Client)
|
## (Server -> Client)
|
||||||
These packets are sent from the multiworld server to the client. They are not messages which the server accepts.
|
These packets are are sent from the multiworld server to the client. They are not messages which the server accepts.
|
||||||
* [RoomInfo](#RoomInfo)
|
* [RoomInfo](#RoomInfo)
|
||||||
* [ConnectionRefused](#ConnectionRefused)
|
* [ConnectionRefused](#ConnectionRefused)
|
||||||
* [Connected](#Connected)
|
* [Connected](#Connected)
|
||||||
@@ -80,6 +80,7 @@ Sent to clients when they connect to an Archipelago server.
|
|||||||
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
||||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
||||||
| games | list\[str\] | List of games present in this multiworld. |
|
| games | list\[str\] | List of games present in this multiworld. |
|
||||||
|
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** |
|
||||||
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
|
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
|
||||||
| seed_name | str | Uniquely identifying name of this generation |
|
| seed_name | str | Uniquely identifying name of this generation |
|
||||||
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
||||||
@@ -499,9 +500,9 @@ In JSON this may look like:
|
|||||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`item` is the item id of the item. Item ids are in the range of ± 2<sup>53</sup>-1.
|
||||||
|
|
||||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`location` is the location id of the item inside the world. Location ids are in the range of ± 2<sup>53</sup>-1.
|
||||||
|
|
||||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||||
|
|
||||||
@@ -645,47 +646,15 @@ class Hint(typing.NamedTuple):
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Data Package Contents
|
### Data Package Contents
|
||||||
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago
|
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
|
||||||
server most easily and not maintain their own mappings. Some contents include:
|
|
||||||
|
|
||||||
- Name to ID mappings for items and locations.
|
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different version. A special case is datapackage version 0, where it is expected the package is custom and should not be cached.
|
||||||
- A checksum of each game's data package for clients to tell if a cached package is invalid.
|
|
||||||
|
|
||||||
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know
|
Note:
|
||||||
when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different checksum
|
* Any ID is unique to its type across AP: Item 56 only exists once and Location 56 only exists once.
|
||||||
than any locally cached ones.
|
* Any Name is unique to its type across its own Game only: Single Arrow can exist in two games.
|
||||||
|
* The IDs from the game "Archipelago" may be used in any other game.
|
||||||
**Important Notes about IDs and Names**:
|
Especially Location ID -1: Cheat Console and -2: Server (typically Remote Start Inventory)
|
||||||
|
|
||||||
* IDs ≤ 0 are reserved for "Archipelago" and should not be used by other world implementations.
|
|
||||||
* The IDs from the game "Archipelago" (in `worlds/generic`) may be used in any world.
|
|
||||||
* Especially Location ID `-1`: `Cheat Console` and `-2`: `Server` (typically Remote Start Inventory)
|
|
||||||
* Any names and IDs are only unique in its own world data package, but different games may reuse these names or IDs.
|
|
||||||
* At runtime, you will need to look up the game of the player to know which item or location ID/Name to lookup in the
|
|
||||||
data package. This can be easily achieved by reviewing the `slot_info` for a particular player ID prior to lookup.
|
|
||||||
* For example, a data package like this is valid (Some properties such as `checksum` were omitted):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"games": {
|
|
||||||
"Game A": {
|
|
||||||
"location_name_to_id": {
|
|
||||||
"Boss Chest": 40
|
|
||||||
},
|
|
||||||
"item_name_to_id": {
|
|
||||||
"Item X": 12
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Game B": {
|
|
||||||
"location_name_to_id": {
|
|
||||||
"Minigame Prize": 40
|
|
||||||
},
|
|
||||||
"item_name_to_id": {
|
|
||||||
"Item X": 40
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Contents
|
#### Contents
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
@@ -699,6 +668,7 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
|||||||
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
|
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
|
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
|
||||||
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
|
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
|
||||||
|
| version | int | Version number of this game's data. Deprecated. Used by older clients to request an updated datapackage if cache is outdated. |
|
||||||
| checksum | str | A checksum hash of this game's data. |
|
| checksum | str | A checksum hash of this game's data. |
|
||||||
|
|
||||||
### Tags
|
### Tags
|
||||||
|
|||||||
@@ -85,74 +85,18 @@ class ExampleWorld(World):
|
|||||||
options: ExampleGameOptions
|
options: ExampleGameOptions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option Documentation
|
|
||||||
|
|
||||||
Options' [docstrings] are used as their user-facing documentation. They're displayed on the WebHost setup page when a
|
|
||||||
user hovers over the yellow "(?)" icon, and included in the YAML templates generated for each game.
|
|
||||||
|
|
||||||
[docstrings]: /docs/world%20api.md#docstrings
|
|
||||||
|
|
||||||
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
|
||||||
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
|
||||||
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
|
||||||
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
|
|
||||||
|
|
||||||
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
|
||||||
|
|
||||||
```python
|
|
||||||
from worlds.AutoWorld import WebWorld
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleWebWorld(WebWorld):
|
|
||||||
# Render all this world's options as rich text.
|
|
||||||
rich_text_options_doc = True
|
|
||||||
```
|
|
||||||
|
|
||||||
You can set a single option to use rich or plain text by setting
|
|
||||||
`Option.rich_text_doc`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from Options import Toggle, Range, Choice, PerGameCommonOptions
|
|
||||||
|
|
||||||
|
|
||||||
class Difficulty(Choice):
|
|
||||||
"""Sets overall game difficulty.
|
|
||||||
|
|
||||||
- **Easy:** All enemies die in one hit.
|
|
||||||
- **Normal:** Enemies and the player both have normal health bars.
|
|
||||||
- **Hard:** The player dies in one hit."""
|
|
||||||
display_name = "Difficulty"
|
|
||||||
rich_text_doc = True
|
|
||||||
option_easy = 0
|
|
||||||
option_normal = 1
|
|
||||||
option_hard = 2
|
|
||||||
default = 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option Groups
|
### Option Groups
|
||||||
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
|
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
|
||||||
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
|
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
|
||||||
with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
|
group.
|
||||||
appear on the WebHost, with the grouping being collapsed when this is `True`.
|
|
||||||
|
|
||||||
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
|
|
||||||
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
|
|
||||||
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
|
|
||||||
removed from this group.
|
|
||||||
|
|
||||||
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
|
|
||||||
those names, letting you add options to them and change whether they start collapsed. The "Item &
|
|
||||||
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
|
|
||||||
be first, regardless of where it is in your list.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from worlds.AutoWorld import WebWorld
|
from worlds.AutoWorld import WebWorld
|
||||||
from Options import OptionGroup
|
from Options import OptionGroup
|
||||||
from . import Options
|
|
||||||
|
|
||||||
class MyWorldWeb(WebWorld):
|
class MyWorldWeb(WebWorld):
|
||||||
option_groups = [
|
option_groups = [
|
||||||
OptionGroup("Color Options", [
|
OptionGroup('Color Options', [
|
||||||
Options.ColorblindMode,
|
Options.ColorblindMode,
|
||||||
Options.FlashReduction,
|
Options.FlashReduction,
|
||||||
Options.UIColors,
|
Options.UIColors,
|
||||||
@@ -176,8 +120,7 @@ or if I need a boolean object, such as in my slot_data I can access it as:
|
|||||||
start_with_sword = bool(self.options.starting_sword.value)
|
start_with_sword = bool(self.options.starting_sword.value)
|
||||||
```
|
```
|
||||||
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
|
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
|
||||||
strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
|
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
|
||||||
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
|
|
||||||
```python
|
```python
|
||||||
# options.py
|
# options.py
|
||||||
class Logic(Choice):
|
class Logic(Choice):
|
||||||
@@ -189,12 +132,6 @@ class Logic(Choice):
|
|||||||
alias_extra_hard = 2
|
alias_extra_hard = 2
|
||||||
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
|
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
|
||||||
|
|
||||||
class Weapon(Choice):
|
|
||||||
option_none = 0
|
|
||||||
option_sword = 1
|
|
||||||
option_bow = 2
|
|
||||||
option_hammer = 3
|
|
||||||
|
|
||||||
# __init__.py
|
# __init__.py
|
||||||
from .options import Logic
|
from .options import Logic
|
||||||
|
|
||||||
@@ -208,16 +145,6 @@ elif self.options.logic == Logic.option_extreme:
|
|||||||
do_extreme_things()
|
do_extreme_things()
|
||||||
elif self.options.logic == "crazy":
|
elif self.options.logic == "crazy":
|
||||||
do_insane_things()
|
do_insane_things()
|
||||||
|
|
||||||
# check if the current option is in a collection of integers using the class attributes
|
|
||||||
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
|
|
||||||
do_stuff()
|
|
||||||
# in order to make a set of strings work, we have to compare against current_key
|
|
||||||
elif self.options.weapon.current_key in {"none", "hammer"}:
|
|
||||||
do_something_else()
|
|
||||||
# though it's usually better to just use a tuple instead
|
|
||||||
elif self.options.weapon in ("none", "hammer"):
|
|
||||||
do_something_else()
|
|
||||||
```
|
```
|
||||||
## Generic Option Classes
|
## Generic Option Classes
|
||||||
These options are generically available to every game automatically, but can be overridden for slightly different
|
These options are generically available to every game automatically, but can be overridden for slightly different
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ webhost:
|
|||||||
|
|
||||||
* `options_page` can be changed to a link instead of an AP-generated options page.
|
* `options_page` can be changed to a link instead of an AP-generated options page.
|
||||||
|
|
||||||
* `rich_text_options_doc` controls whether [Option documentation] uses plain text (`False`) or rich text (`True`). It
|
|
||||||
defaults to `False`, but world authors are encouraged to set it to `True` for nicer-looking documentation that looks
|
|
||||||
good on both the WebHost and the YAML template.
|
|
||||||
|
|
||||||
[Option documentation]: /docs/options%20api.md#option-documentation
|
|
||||||
|
|
||||||
* `theme` to be used for your game-specific AP pages. Available themes:
|
* `theme` to be used for your game-specific AP pages. Available themes:
|
||||||
|
|
||||||
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
|
|||||||
[Run]
|
[Run]
|
||||||
|
|
||||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
|
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
|
||||||
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
|
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites
|
||||||
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
|
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
|
||||||
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
@@ -87,14 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
|
|||||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||||
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
|
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
|
||||||
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
|
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
|
|
||||||
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
|
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
|
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
|
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
|
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
|
|
||||||
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
|
|
||||||
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
||||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||||
#include "installdelete.iss"
|
#include "installdelete.iss"
|
||||||
@@ -216,11 +209,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
|
|||||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
|
||||||
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
|
||||||
|
|
||||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
|
||||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
|
||||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
|
||||||
|
|||||||
135
intset.h
135
intset.h
@@ -1,135 +0,0 @@
|
|||||||
/* A specialized unordered_set implementation for literals, where bucket_count
|
|
||||||
* is defined at initialization rather than increased automatically.
|
|
||||||
*/
|
|
||||||
#include <stddef.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#ifndef INTSET_NAME
|
|
||||||
#error "Please #define INTSET_NAME ... before including intset.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifndef INTSET_TYPE
|
|
||||||
#error "Please #define INTSET_TYPE ... before including intset.h"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* macros to generate unique names from INTSET_NAME */
|
|
||||||
#ifndef INTSET_CONCAT
|
|
||||||
#define INTSET_CONCAT_(a, b) a ## b
|
|
||||||
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
|
|
||||||
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
|
|
||||||
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
|
|
||||||
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
|
|
||||||
|
|
||||||
#if defined(_MSC_VER)
|
|
||||||
#pragma warning(push)
|
|
||||||
#pragma warning(disable : 4200)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
size_t count;
|
|
||||||
union INTSET_UNION {
|
|
||||||
INTSET_TYPE val;
|
|
||||||
INTSET_TYPE *data;
|
|
||||||
} v;
|
|
||||||
} INTSET_BUCKET;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
size_t bucket_count;
|
|
||||||
INTSET_BUCKET buckets[];
|
|
||||||
} INTSET_NAME;
|
|
||||||
|
|
||||||
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
|
|
||||||
{
|
|
||||||
size_t i, size;
|
|
||||||
INTSET_NAME *set;
|
|
||||||
|
|
||||||
if (buckets < 1)
|
|
||||||
buckets = 1;
|
|
||||||
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
|
|
||||||
return NULL;
|
|
||||||
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
|
|
||||||
set = (INTSET_NAME*)malloc(size);
|
|
||||||
if (!set)
|
|
||||||
return NULL;
|
|
||||||
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
|
|
||||||
for (i = 0; i < buckets; i++) {
|
|
||||||
set->buckets[i].count = 0;
|
|
||||||
}
|
|
||||||
set->bucket_count = buckets;
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void INTSET_FUNC(free)(INTSET_NAME *set)
|
|
||||||
{
|
|
||||||
size_t i;
|
|
||||||
if (!set)
|
|
||||||
return;
|
|
||||||
for (i = 0; i < set->bucket_count; i++) {
|
|
||||||
if (set->buckets[i].count > 1)
|
|
||||||
free(set->buckets[i].v.data);
|
|
||||||
}
|
|
||||||
free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
|
|
||||||
{
|
|
||||||
size_t i;
|
|
||||||
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
|
|
||||||
if (bucket->count == 1)
|
|
||||||
return bucket->v.val == val;
|
|
||||||
for (i = 0; i < bucket->count; ++i) {
|
|
||||||
if (bucket->v.data[i] == val)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
|
|
||||||
{
|
|
||||||
INTSET_BUCKET* bucket;
|
|
||||||
|
|
||||||
if (INTSET_FUNC(contains)(set, val))
|
|
||||||
return true; /* ok */
|
|
||||||
|
|
||||||
bucket = &set->buckets[(size_t)val % set->bucket_count];
|
|
||||||
if (bucket->count == 0) {
|
|
||||||
bucket->v.val = val;
|
|
||||||
bucket->count = 1;
|
|
||||||
} else if (bucket->count == 1) {
|
|
||||||
INTSET_TYPE old = bucket->v.val;
|
|
||||||
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
|
|
||||||
if (!bucket->v.data) {
|
|
||||||
bucket->v.val = old;
|
|
||||||
return false; /* error */
|
|
||||||
}
|
|
||||||
bucket->v.data[0] = old;
|
|
||||||
bucket->v.data[1] = val;
|
|
||||||
bucket->count = 2;
|
|
||||||
} else {
|
|
||||||
size_t new_bucket_size;
|
|
||||||
INTSET_TYPE* new_bucket_data;
|
|
||||||
|
|
||||||
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
|
|
||||||
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
|
|
||||||
if (!new_bucket_data)
|
|
||||||
return false; /* error */
|
|
||||||
bucket->v.data = new_bucket_data;
|
|
||||||
bucket->v.data[bucket->count++] = val;
|
|
||||||
}
|
|
||||||
return true; /* success */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#if defined(_MSC_VER)
|
|
||||||
#pragma warning(pop)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#undef INTSET_FUNC
|
|
||||||
#undef INTSET_BUCKET
|
|
||||||
#undef INTSET_UNION
|
|
||||||
92
kvui.py
92
kvui.py
@@ -3,7 +3,6 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import re
|
import re
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -65,7 +64,7 @@ from kivy.uix.popup import Popup
|
|||||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||||
|
|
||||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||||
from Utils import async_start, get_input_text_from_response
|
from Utils import async_start
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import CommonClient
|
import CommonClient
|
||||||
@@ -286,10 +285,16 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
|||||||
temp = MarkupLabel(text=self.text).markup
|
temp = MarkupLabel(text=self.text).markup
|
||||||
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
||||||
cmdinput = App.get_running_app().textinput
|
cmdinput = App.get_running_app().textinput
|
||||||
if not cmdinput.text:
|
if not cmdinput.text and " did you mean " in text:
|
||||||
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
|
for question in ("Didn't find something that closely matches, did you mean ",
|
||||||
if input_text is not None:
|
"Too many close matches, did you mean "):
|
||||||
cmdinput.text = input_text
|
if text.startswith(question):
|
||||||
|
name = Utils.get_text_between(text, question,
|
||||||
|
"? (")
|
||||||
|
cmdinput.text = f"!{App.get_running_app().last_autofillable_command} {name}"
|
||||||
|
break
|
||||||
|
elif not cmdinput.text and text.startswith("Missing: "):
|
||||||
|
cmdinput.text = text.replace("Missing: ", "!hint_location ")
|
||||||
|
|
||||||
Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
||||||
return self.parent.select_with_touch(self.index, touch)
|
return self.parent.select_with_touch(self.index, touch)
|
||||||
@@ -381,57 +386,6 @@ class ConnectBarTextInput(TextInput):
|
|||||||
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
||||||
|
|
||||||
|
|
||||||
def is_command_input(string: str) -> bool:
|
|
||||||
return len(string) > 0 and string[0] in "/!"
|
|
||||||
|
|
||||||
|
|
||||||
class CommandPromptTextInput(TextInput):
|
|
||||||
MAXIMUM_HISTORY_MESSAGES = 50
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._command_history_index = -1
|
|
||||||
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
|
|
||||||
|
|
||||||
def update_history(self, new_entry: str) -> None:
|
|
||||||
self._command_history_index = -1
|
|
||||||
if is_command_input(new_entry):
|
|
||||||
self._command_history.appendleft(new_entry)
|
|
||||||
|
|
||||||
def keyboard_on_key_down(
|
|
||||||
self,
|
|
||||||
window,
|
|
||||||
keycode: typing.Tuple[int, str],
|
|
||||||
text: typing.Optional[str],
|
|
||||||
modifiers: typing.List[str]
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
:param window: The kivy window object
|
|
||||||
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
|
|
||||||
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
|
|
||||||
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
|
|
||||||
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
|
|
||||||
"""
|
|
||||||
if keycode[1] == 'up':
|
|
||||||
self._change_to_history_text_if_available(self._command_history_index + 1)
|
|
||||||
return True
|
|
||||||
if keycode[1] == 'down':
|
|
||||||
self._change_to_history_text_if_available(self._command_history_index - 1)
|
|
||||||
return True
|
|
||||||
return super().keyboard_on_key_down(window, keycode, text, modifiers)
|
|
||||||
|
|
||||||
def _change_to_history_text_if_available(self, new_index: int) -> None:
|
|
||||||
if new_index < -1:
|
|
||||||
return
|
|
||||||
if new_index >= len(self._command_history):
|
|
||||||
return
|
|
||||||
self._command_history_index = new_index
|
|
||||||
if new_index == -1:
|
|
||||||
self.text = ""
|
|
||||||
return
|
|
||||||
self.text = self._command_history[self._command_history_index]
|
|
||||||
|
|
||||||
|
|
||||||
class MessageBox(Popup):
|
class MessageBox(Popup):
|
||||||
class MessageBoxLabel(Label):
|
class MessageBoxLabel(Label):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -467,7 +421,7 @@ class GameManager(App):
|
|||||||
self.commandprocessor = ctx.command_processor(ctx)
|
self.commandprocessor = ctx.command_processor(ctx)
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
|
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
|
||||||
self.log_panels: typing.Dict[str, Widget] = {}
|
self.log_panels = {}
|
||||||
|
|
||||||
# keep track of last used command to autofill on click
|
# keep track of last used command to autofill on click
|
||||||
self.last_autofillable_command = "hint"
|
self.last_autofillable_command = "hint"
|
||||||
@@ -551,7 +505,7 @@ class GameManager(App):
|
|||||||
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
|
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
|
||||||
info_button.bind(on_release=self.command_button_action)
|
info_button.bind(on_release=self.command_button_action)
|
||||||
bottom_layout.add_widget(info_button)
|
bottom_layout.add_widget(info_button)
|
||||||
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||||
self.textinput.bind(on_text_validate=self.on_message)
|
self.textinput.bind(on_text_validate=self.on_message)
|
||||||
self.textinput.text_validate_unfocus = False
|
self.textinput.text_validate_unfocus = False
|
||||||
bottom_layout.add_widget(self.textinput)
|
bottom_layout.add_widget(self.textinput)
|
||||||
@@ -609,18 +563,14 @@ class GameManager(App):
|
|||||||
|
|
||||||
self.ctx.exit_event.set()
|
self.ctx.exit_event.set()
|
||||||
|
|
||||||
def on_message(self, textinput: CommandPromptTextInput):
|
def on_message(self, textinput: TextInput):
|
||||||
try:
|
try:
|
||||||
input_text = textinput.text.strip()
|
input_text = textinput.text.strip()
|
||||||
textinput.text = ""
|
textinput.text = ""
|
||||||
textinput.update_history(input_text)
|
|
||||||
|
|
||||||
if self.ctx.input_requests > 0:
|
if self.ctx.input_requests > 0:
|
||||||
self.ctx.input_requests -= 1
|
self.ctx.input_requests -= 1
|
||||||
self.ctx.input_queue.put_nowait(input_text)
|
self.ctx.input_queue.put_nowait(input_text)
|
||||||
elif is_command_input(input_text):
|
|
||||||
self.ctx.on_ui_command(input_text)
|
|
||||||
self.commandprocessor(input_text)
|
|
||||||
elif input_text:
|
elif input_text:
|
||||||
self.commandprocessor(input_text)
|
self.commandprocessor(input_text)
|
||||||
|
|
||||||
@@ -733,18 +683,10 @@ class HintLog(RecycleView):
|
|||||||
for hint in hints:
|
for hint in hints:
|
||||||
data.append({
|
data.append({
|
||||||
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
|
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
|
||||||
"item": {"text": self.parser.handle_node({
|
"item": {"text": self.parser.handle_node(
|
||||||
"type": "item_id",
|
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
|
||||||
"text": hint["item"],
|
|
||||||
"flags": hint["item_flags"],
|
|
||||||
"player": hint["receiving_player"],
|
|
||||||
})},
|
|
||||||
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
|
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
|
||||||
"location": {"text": self.parser.handle_node({
|
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})},
|
||||||
"type": "location_id",
|
|
||||||
"text": hint["location"],
|
|
||||||
"player": hint["finding_player"],
|
|
||||||
})},
|
|
||||||
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
|
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
|
||||||
"color": "blue", "text": hint["entrance"]
|
"color": "blue", "text": hint["entrance"]
|
||||||
if hint["entrance"] else "Vanilla"})},
|
if hint["entrance"] else "Vanilla"})},
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ colorama>=0.4.6
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
PyYAML>=6.0.1
|
PyYAML>=6.0.1
|
||||||
jellyfish>=1.0.3
|
jellyfish>=1.0.3
|
||||||
jinja2>=3.1.4
|
jinja2>=3.1.3
|
||||||
schema>=0.7.7
|
schema>=0.7.5
|
||||||
kivy>=2.3.0
|
kivy>=2.3.0
|
||||||
bsdiff4>=1.2.4
|
bsdiff4>=1.2.4
|
||||||
platformdirs>=4.2.2
|
platformdirs>=4.1.0
|
||||||
certifi>=2024.6.2
|
certifi>=2023.11.17
|
||||||
cython>=3.0.10
|
cython>=3.0.8
|
||||||
cymem>=2.0.8
|
cymem>=2.0.8
|
||||||
orjson>=3.10.3
|
orjson>=3.9.10
|
||||||
typing_extensions>=4.12.1
|
typing_extensions>=4.7.0
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -190,7 +190,7 @@ if is_windows:
|
|||||||
c = next(component for component in components if component.script_name == "Launcher")
|
c = next(component for component in components if component.script_name == "Launcher")
|
||||||
exes.append(cx_Freeze.Executable(
|
exes.append(cx_Freeze.Executable(
|
||||||
script=f"{c.script_name}.py",
|
script=f"{c.script_name}.py",
|
||||||
target_name=f"{c.frozen_name}Debug.exe",
|
target_name=f"{c.frozen_name}(DEBUG).exe",
|
||||||
icon=resolve_icon(c.icon),
|
icon=resolve_icon(c.icon),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -292,12 +292,12 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
"""Ensure all state can reach everything and complete the game with the defined options"""
|
"""Ensure all state can reach everything and complete the game with the defined options"""
|
||||||
if not (self.run_default_tests and self.constructed):
|
if not (self.run_default_tests and self.constructed):
|
||||||
return
|
return
|
||||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
with self.subTest("Game", game=self.game):
|
||||||
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
|
||||||
state = self.multiworld.get_all_state(False)
|
state = self.multiworld.get_all_state(False)
|
||||||
for location in self.multiworld.get_locations():
|
for location in self.multiworld.get_locations():
|
||||||
if location.name not in excluded:
|
if location.name not in excluded:
|
||||||
with self.subTest("Location should be reached", location=location.name):
|
with self.subTest("Location should be reached", location=location):
|
||||||
reachable = location.can_reach(state)
|
reachable = location.can_reach(state)
|
||||||
self.assertTrue(reachable, f"{location.name} unreachable")
|
self.assertTrue(reachable, f"{location.name} unreachable")
|
||||||
with self.subTest("Beatable"):
|
with self.subTest("Beatable"):
|
||||||
@@ -308,7 +308,7 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
"""Ensure empty state can reach at least one location with the defined options"""
|
"""Ensure empty state can reach at least one location with the defined options"""
|
||||||
if not (self.run_default_tests and self.constructed):
|
if not (self.run_default_tests and self.constructed):
|
||||||
return
|
return
|
||||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
with self.subTest("Game", game=self.game):
|
||||||
state = CollectionState(self.multiworld)
|
state = CollectionState(self.multiworld)
|
||||||
locations = self.multiworld.get_reachable_locations(state, self.player)
|
locations = self.multiworld.get_reachable_locations(state, self.player)
|
||||||
self.assertGreater(len(locations), 0,
|
self.assertGreater(len(locations), 0,
|
||||||
@@ -329,7 +329,7 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
for n in range(len(locations) - 1, -1, -1):
|
for n in range(len(locations) - 1, -1, -1):
|
||||||
if locations[n].can_reach(state):
|
if locations[n].can_reach(state):
|
||||||
sphere.append(locations.pop(n))
|
sphere.append(locations.pop(n))
|
||||||
self.assertTrue(sphere or self.multiworld.worlds[1].options.accessibility == "minimal",
|
self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal",
|
||||||
f"Unreachable locations: {locations}")
|
f"Unreachable locations: {locations}")
|
||||||
if not sphere:
|
if not sphere:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.5)
|
|
||||||
project(ap-cpp-tests)
|
|
||||||
|
|
||||||
enable_testing()
|
|
||||||
|
|
||||||
find_package(GTest REQUIRED)
|
|
||||||
|
|
||||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
|
||||||
add_definitions("/source-charset:utf-8")
|
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
|
||||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
|
||||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
|
||||||
# enable static analysis for gcc
|
|
||||||
add_compile_options(-fanalyzer -Werror)
|
|
||||||
# disable stuff that gets triggered by googletest
|
|
||||||
add_compile_options(-Wno-analyzer-malloc-leak)
|
|
||||||
# enable asan for gcc
|
|
||||||
add_compile_options(-fsanitize=address)
|
|
||||||
add_link_options(-fsanitize=address)
|
|
||||||
endif ()
|
|
||||||
|
|
||||||
add_executable(test_default)
|
|
||||||
|
|
||||||
target_include_directories(test_default
|
|
||||||
PRIVATE
|
|
||||||
${GTEST_INCLUDE_DIRS}
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(test_default
|
|
||||||
${GTEST_BOTH_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
add_test(
|
|
||||||
NAME test_default
|
|
||||||
COMMAND test_default
|
|
||||||
)
|
|
||||||
|
|
||||||
set_property(
|
|
||||||
TEST test_default
|
|
||||||
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
|
|
||||||
)
|
|
||||||
|
|
||||||
file(GLOB ITEMS *)
|
|
||||||
foreach(item ${ITEMS})
|
|
||||||
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
|
|
||||||
message(${item})
|
|
||||||
add_subdirectory(${item})
|
|
||||||
endif()
|
|
||||||
endforeach()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# C++ tests
|
|
||||||
|
|
||||||
Test framework for C and C++ code in AP.
|
|
||||||
|
|
||||||
## Adding a Test
|
|
||||||
|
|
||||||
### GoogleTest
|
|
||||||
|
|
||||||
Adding GoogleTests is as simple as creating a directory with
|
|
||||||
* one or more `test_*.cpp` files that define tests using
|
|
||||||
[GoogleTest API](https://google.github.io/googletest/)
|
|
||||||
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
|
|
||||||
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
|
|
||||||
|
|
||||||
### CTest
|
|
||||||
|
|
||||||
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
|
|
||||||
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
* Install [CMake](https://cmake.org/).
|
|
||||||
* Build and/or install GoogleTest and make sure
|
|
||||||
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
|
|
||||||
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
|
|
||||||
* Enter the directory with the top-most `CMakeLists.txt` and run
|
|
||||||
```sh
|
|
||||||
mkdir build
|
|
||||||
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
|
|
||||||
cmake --build build/ --config Release && \
|
|
||||||
ctest --test-dir build/ -C Release --output-on-failure
|
|
||||||
```
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
target_sources(test_default
|
|
||||||
PRIVATE
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
|
|
||||||
)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
#include <limits>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <gtest/gtest.h>
|
|
||||||
|
|
||||||
// uint32Set
|
|
||||||
#define INTSET_NAME uint32Set
|
|
||||||
#define INTSET_TYPE uint32_t
|
|
||||||
#include "../../../intset.h"
|
|
||||||
#undef INTSET_NAME
|
|
||||||
#undef INTSET_TYPE
|
|
||||||
|
|
||||||
// int64Set
|
|
||||||
#define INTSET_NAME int64Set
|
|
||||||
#define INTSET_TYPE int64_t
|
|
||||||
#include "../../../intset.h"
|
|
||||||
|
|
||||||
|
|
||||||
TEST(IntsetTest, ZeroBuckets)
|
|
||||||
{
|
|
||||||
// trying to allocate with zero buckets has to either fail or be functioning
|
|
||||||
uint32Set *set = uint32Set_new(0);
|
|
||||||
if (!set)
|
|
||||||
return; // failed -> OK
|
|
||||||
|
|
||||||
EXPECT_FALSE(uint32Set_contains(set, 1));
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, 1));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, 1));
|
|
||||||
uint32Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntsetTest, Duplicate)
|
|
||||||
{
|
|
||||||
// adding the same number again can't fail
|
|
||||||
uint32Set *set = uint32Set_new(2);
|
|
||||||
ASSERT_TRUE(set);
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
|
||||||
uint32Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntsetTest, SetAllocFailure)
|
|
||||||
{
|
|
||||||
// try to allocate 100TB of RAM, should fail and return NULL
|
|
||||||
if (sizeof(size_t) < 8)
|
|
||||||
GTEST_SKIP() << "Alloc error not testable on 32bit";
|
|
||||||
int64Set *set = int64Set_new(6250000000000ULL);
|
|
||||||
EXPECT_FALSE(set);
|
|
||||||
int64Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntsetTest, SetAllocOverflow)
|
|
||||||
{
|
|
||||||
// try to overflow argument passed to malloc
|
|
||||||
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
|
|
||||||
EXPECT_FALSE(set);
|
|
||||||
int64Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntsetTest, NullFree)
|
|
||||||
{
|
|
||||||
// free(NULL) should not try to free buckets
|
|
||||||
uint32Set_free(NULL);
|
|
||||||
int64Set_free(NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntsetTest, BucketRealloc)
|
|
||||||
{
|
|
||||||
// add a couple of values to the same bucket to test growing the bucket
|
|
||||||
uint32Set* set = uint32Set_new(1);
|
|
||||||
ASSERT_TRUE(set);
|
|
||||||
EXPECT_FALSE(uint32Set_contains(set, 0));
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, 0));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, 0));
|
|
||||||
for (uint32_t i = 1; i < 32; ++i) {
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, i));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, i - 1));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, i));
|
|
||||||
EXPECT_FALSE(uint32Set_contains(set, i + 1));
|
|
||||||
}
|
|
||||||
uint32Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(IntSet, Max)
|
|
||||||
{
|
|
||||||
constexpr auto n = std::numeric_limits<uint32_t>::max();
|
|
||||||
uint32Set *set = uint32Set_new(1);
|
|
||||||
ASSERT_TRUE(set);
|
|
||||||
EXPECT_FALSE(uint32Set_contains(set, n));
|
|
||||||
EXPECT_TRUE(uint32Set_add(set, n));
|
|
||||||
EXPECT_TRUE(uint32Set_contains(set, n));
|
|
||||||
uint32Set_free(set);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST(InsetTest, Negative)
|
|
||||||
{
|
|
||||||
constexpr auto n = std::numeric_limits<int64_t>::min();
|
|
||||||
static_assert(n < 0, "n not negative");
|
|
||||||
int64Set *set = int64Set_new(3);
|
|
||||||
ASSERT_TRUE(set);
|
|
||||||
EXPECT_FALSE(int64Set_contains(set, n));
|
|
||||||
EXPECT_TRUE(int64Set_add(set, n));
|
|
||||||
EXPECT_TRUE(int64Set_contains(set, n));
|
|
||||||
int64Set_free(set);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ from argparse import Namespace
|
|||||||
from typing import List, Optional, Tuple, Type, Union
|
from typing import List, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||||
from worlds import network_data_package
|
|
||||||
from worlds.AutoWorld import World, call_all
|
from worlds.AutoWorld import World, call_all
|
||||||
|
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||||
@@ -61,10 +60,6 @@ class TestWorld(World):
|
|||||||
hidden = True
|
hidden = True
|
||||||
|
|
||||||
|
|
||||||
# add our test world to the data package, so we can test it later
|
|
||||||
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_test_multiworld(players: int = 1) -> MultiWorld:
|
def generate_test_multiworld(players: int = 1) -> MultiWorld:
|
||||||
"""
|
"""
|
||||||
Generates a multiworld using a special Test Case World class, and seed of 0.
|
Generates a multiworld using a special Test Case World class, and seed of 0.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from Utils import get_intended_text, get_input_text_from_response
|
|
||||||
|
|
||||||
|
|
||||||
class TestClient(unittest.TestCase):
|
|
||||||
def test_autofill_hint_from_fuzzy_hint(self) -> None:
|
|
||||||
tests = (
|
|
||||||
("item", ["item1", "item2"]), # Multiple close matches
|
|
||||||
("itm", ["item1", "item21"]), # No close match, multiple option
|
|
||||||
("item", ["item1"]), # No close match, single option
|
|
||||||
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
|
|
||||||
)
|
|
||||||
|
|
||||||
for input_text, possible_answers in tests:
|
|
||||||
item_name, usable, response = get_intended_text(input_text, possible_answers)
|
|
||||||
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
|
|
||||||
|
|
||||||
hint_command = get_input_text_from_response(response, "hint")
|
|
||||||
self.assertIsNotNone(hint_command,
|
|
||||||
"The response to fuzzy hints is no longer recognized by the hint autofill")
|
|
||||||
self.assertEqual(hint_command, f"!hint {item_name}",
|
|
||||||
"The hint command autofilled by the response is not correct")
|
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from Fill import distribute_items_restrictive
|
from Fill import distribute_items_restrictive
|
||||||
from worlds import network_data_package
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
|
|
||||||
class TestIDs(unittest.TestCase):
|
class TestIDs(unittest.TestCase):
|
||||||
|
def test_unique_items(self):
|
||||||
|
"""Tests that every game has a unique ID per item in the datapackage"""
|
||||||
|
known_item_ids = set()
|
||||||
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
current = len(known_item_ids)
|
||||||
|
known_item_ids |= set(world_type.item_id_to_name)
|
||||||
|
self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current)
|
||||||
|
|
||||||
|
def test_unique_locations(self):
|
||||||
|
"""Tests that every game has a unique ID per location in the datapackage"""
|
||||||
|
known_location_ids = set()
|
||||||
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
current = len(known_location_ids)
|
||||||
|
known_location_ids |= set(world_type.location_id_to_name)
|
||||||
|
self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current)
|
||||||
|
|
||||||
def test_range_items(self):
|
def test_range_items(self):
|
||||||
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
|
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
@@ -85,4 +100,3 @@ class TestIDs(unittest.TestCase):
|
|||||||
f"{loc_name} is not a valid item name for location_name_to_id")
|
f"{loc_name} is not a valid item name for location_name_to_id")
|
||||||
self.assertIsInstance(loc_id, int,
|
self.assertIsInstance(loc_id, int,
|
||||||
f"{loc_id} for {loc_name} should be an int")
|
f"{loc_id} for {loc_name} should be an int")
|
||||||
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])
|
|
||||||
|
|||||||
@@ -41,15 +41,15 @@ class TestBase(unittest.TestCase):
|
|||||||
state = multiworld.get_all_state(False)
|
state = multiworld.get_all_state(False)
|
||||||
for location in multiworld.get_locations():
|
for location in multiworld.get_locations():
|
||||||
if location.name not in excluded:
|
if location.name not in excluded:
|
||||||
with self.subTest("Location should be reached", location=location.name):
|
with self.subTest("Location should be reached", location=location):
|
||||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||||
|
|
||||||
for region in multiworld.get_regions():
|
for region in multiworld.get_regions():
|
||||||
if region.name in unreachable_regions:
|
if region.name in unreachable_regions:
|
||||||
with self.subTest("Region should be unreachable", region=region.name):
|
with self.subTest("Region should be unreachable", region=region):
|
||||||
self.assertFalse(region.can_reach(state))
|
self.assertFalse(region.can_reach(state))
|
||||||
else:
|
else:
|
||||||
with self.subTest("Region should be reached", region=region.name):
|
with self.subTest("Region should be reached", region=region):
|
||||||
self.assertTrue(region.can_reach(state))
|
self.assertTrue(region.can_reach(state))
|
||||||
|
|
||||||
with self.subTest("Completion Condition"):
|
with self.subTest("Completion Condition"):
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
|
|
||||||
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
|
|
||||||
# Run with `python test/hosting` instead,
|
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
from time import sleep
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from test.hosting.client import Client
|
|
||||||
from test.hosting.generate import generate_local
|
|
||||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
|
||||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
|
||||||
stop_autohost, upload_multidata)
|
|
||||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
|
||||||
|
|
||||||
failure = False
|
|
||||||
fail_fast = True
|
|
||||||
|
|
||||||
|
|
||||||
def assert_true(condition: Any, msg: str = "") -> None:
|
|
||||||
global failure
|
|
||||||
if not condition:
|
|
||||||
failure = True
|
|
||||||
msg = f": {msg}" if msg else ""
|
|
||||||
raise AssertionError(f"Assertion failed{msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
|
|
||||||
global failure
|
|
||||||
if first != second:
|
|
||||||
failure = True
|
|
||||||
msg = f": {msg}" if msg else ""
|
|
||||||
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
|
|
||||||
|
|
||||||
|
|
||||||
if fail_fast:
|
|
||||||
expect_true = assert_true
|
|
||||||
expect_equal = assert_equal
|
|
||||||
else:
|
|
||||||
def expect_true(condition: Any, msg: str = "") -> None:
|
|
||||||
global failure
|
|
||||||
if not condition:
|
|
||||||
failure = True
|
|
||||||
tb = "".join(traceback.format_stack()[:-1])
|
|
||||||
msg = f": {msg}" if msg else ""
|
|
||||||
logging.error(f"Expectation failed{msg}\n{tb}")
|
|
||||||
|
|
||||||
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
|
|
||||||
global failure
|
|
||||||
if first != second:
|
|
||||||
failure = True
|
|
||||||
tb = "".join(traceback.format_stack()[:-1])
|
|
||||||
msg = f": {msg}" if msg else ""
|
|
||||||
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import warnings
|
|
||||||
warnings.simplefilter("ignore", ResourceWarning)
|
|
||||||
warnings.simplefilter("ignore", UserWarning)
|
|
||||||
|
|
||||||
spacer = '=' * 80
|
|
||||||
|
|
||||||
with TemporaryDirectory() as tempdir:
|
|
||||||
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
|
|
||||||
p1_games = []
|
|
||||||
data_paths = []
|
|
||||||
rooms = []
|
|
||||||
|
|
||||||
copy_world("Clique", "Temp World")
|
|
||||||
try:
|
|
||||||
for n, games in enumerate(multis, 1):
|
|
||||||
print(f"Generating [{n}] {', '.join(games)}")
|
|
||||||
multidata = generate_local(games, tempdir)
|
|
||||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
|
||||||
p1_games.append(games[0])
|
|
||||||
data_paths.append(multidata)
|
|
||||||
finally:
|
|
||||||
delete_world("Temp World")
|
|
||||||
|
|
||||||
webapp = get_app(tempdir)
|
|
||||||
webhost_client = webapp.test_client()
|
|
||||||
for n, multidata in enumerate(data_paths, 1):
|
|
||||||
seed = upload_multidata(webhost_client, multidata)
|
|
||||||
room = create_room(webhost_client, seed)
|
|
||||||
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
|
||||||
rooms.append(room)
|
|
||||||
|
|
||||||
print("Starting autohost")
|
|
||||||
from WebHostLib.autolauncher import autohost
|
|
||||||
try:
|
|
||||||
autohost(webapp.config)
|
|
||||||
|
|
||||||
host: ServeGame
|
|
||||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
|
||||||
involved_games = {"Archipelago"} | set(multi_games)
|
|
||||||
for collected_items in range(3):
|
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
|
||||||
with LocalServeGame(multidata) as host:
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
|
||||||
local_data_packages = client.games_packages
|
|
||||||
local_collected_items = len(client.checked_locations)
|
|
||||||
if collected_items < 2: # Clique only has 2 Locations
|
|
||||||
client.collect_any()
|
|
||||||
# TODO: Ctrl+C test here as well
|
|
||||||
|
|
||||||
for game_name in sorted(involved_games):
|
|
||||||
expect_true(game_name in local_data_packages,
|
|
||||||
f"{game_name} missing from MultiServer datap ackage")
|
|
||||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
for game_name in local_data_packages:
|
|
||||||
expect_true(game_name in involved_games,
|
|
||||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
|
||||||
assert_equal(local_collected_items, collected_items,
|
|
||||||
"MultiServer did not load or save correctly")
|
|
||||||
|
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
|
||||||
prev_host_adr: str
|
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
|
||||||
prev_host_adr = host.address
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
|
||||||
web_data_packages = client.games_packages
|
|
||||||
web_collected_items = len(client.checked_locations)
|
|
||||||
if collected_items < 2: # Clique only has 2 Locations
|
|
||||||
client.collect_any()
|
|
||||||
if collected_items == 1:
|
|
||||||
sleep(1) # wait for the server to collect the item
|
|
||||||
stop_autohost(True) # simulate Ctrl+C
|
|
||||||
sleep(3)
|
|
||||||
autohost(webapp.config) # this will spin the room right up again
|
|
||||||
sleep(1) # make log less annoying
|
|
||||||
# if saving failed, the next iteration will fail below
|
|
||||||
|
|
||||||
# verify server shut down
|
|
||||||
try:
|
|
||||||
with Client(prev_host_adr, game, "Player1") as client:
|
|
||||||
assert_true(False, "Server did not shut down")
|
|
||||||
except ConnectionError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for game_name in sorted(involved_games):
|
|
||||||
expect_true(game_name in web_data_packages,
|
|
||||||
f"{game_name} missing from customserver data package")
|
|
||||||
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
|
|
||||||
f"item_name_groups are not supposed to be in customserver data for {game_name}")
|
|
||||||
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
|
|
||||||
f"location_name_groups are not supposed to be in customserver data for {game_name}")
|
|
||||||
for game_name in web_data_packages:
|
|
||||||
expect_true(game_name in involved_games,
|
|
||||||
f"Received unexpected extra data package for {game_name} from customserver")
|
|
||||||
assert_equal(web_collected_items, collected_items,
|
|
||||||
"customserver did not load or save correctly during/after "
|
|
||||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
|
||||||
|
|
||||||
# compare customserver to MultiServer
|
|
||||||
expect_equal(local_data_packages, web_data_packages,
|
|
||||||
"customserver datapackage differs from MultiServer")
|
|
||||||
|
|
||||||
sleep(5.5) # make sure all tasks actually stopped
|
|
||||||
|
|
||||||
# raise an exception in customserver and verify the save doesn't get destroyed
|
|
||||||
# local variables room is the last room's id here
|
|
||||||
old_data = get_multidata_for_room(webhost_client, room)
|
|
||||||
print(f"Destroying multidata for {room}")
|
|
||||||
set_multidata_for_room(webhost_client, room, bytes([0]))
|
|
||||||
try:
|
|
||||||
start_room(webhost_client, room, timeout=7)
|
|
||||||
except TimeoutError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
assert_true(False, "Room started with destroyed multidata")
|
|
||||||
print(f"Restoring multidata for {room}")
|
|
||||||
set_multidata_for_room(webhost_client, room, old_data)
|
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
|
||||||
assert_equal(len(client.checked_locations), 2,
|
|
||||||
"Save was destroyed during exception in customserver")
|
|
||||||
print("Save file is not busted 🥳")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
print("Stopping autohost")
|
|
||||||
stop_autohost(False)
|
|
||||||
|
|
||||||
if failure:
|
|
||||||
print("Some tests failed")
|
|
||||||
exit(1)
|
|
||||||
exit(0)
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
from typing import Any, Collection, Dict, Iterable, Optional
|
|
||||||
from websockets import ConnectionClosed
|
|
||||||
from websockets.sync.client import connect, ClientConnection
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Client"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
"""Incomplete, minimalistic sync test client for AP network protocol"""
|
|
||||||
|
|
||||||
recv_timeout = 1.0
|
|
||||||
|
|
||||||
host: str
|
|
||||||
game: str
|
|
||||||
slot: str
|
|
||||||
password: Optional[str]
|
|
||||||
|
|
||||||
_ws: Optional[ClientConnection]
|
|
||||||
|
|
||||||
games: Iterable[str]
|
|
||||||
data_package_checksums: Dict[str, Any]
|
|
||||||
games_packages: Dict[str, Any]
|
|
||||||
missing_locations: Collection[int]
|
|
||||||
checked_locations: Collection[int]
|
|
||||||
|
|
||||||
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.game = game
|
|
||||||
self.slot = slot
|
|
||||||
self.password = password
|
|
||||||
self._ws = None
|
|
||||||
self.games = []
|
|
||||||
self.data_package_checksums = {}
|
|
||||||
self.games_packages = {}
|
|
||||||
self.missing_locations = []
|
|
||||||
self.checked_locations = []
|
|
||||||
|
|
||||||
def __enter__(self) -> "Client":
|
|
||||||
try:
|
|
||||||
self.connect()
|
|
||||||
except BaseException:
|
|
||||||
self.__exit__(*sys.exc_info())
|
|
||||||
raise
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def _poll(self) -> None:
|
|
||||||
assert self._ws
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
self._ws.recv()
|
|
||||||
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def connect(self) -> None:
|
|
||||||
self._ws = connect(f"ws://{self.host}")
|
|
||||||
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
|
|
||||||
self.games = sorted(room_info["games"])
|
|
||||||
self.data_package_checksums = room_info["datapackage_checksums"]
|
|
||||||
self._ws.send(json.dumps([{
|
|
||||||
"cmd": "GetDataPackage",
|
|
||||||
"games": list(self.games),
|
|
||||||
}]))
|
|
||||||
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
|
||||||
self.games_packages = data_package_msg["data"]["games"]
|
|
||||||
self._ws.send(json.dumps([{
|
|
||||||
"cmd": "Connect",
|
|
||||||
"game": self.game,
|
|
||||||
"name": self.slot,
|
|
||||||
"password": self.password,
|
|
||||||
"uuid": "",
|
|
||||||
"version": {
|
|
||||||
"class": "Version",
|
|
||||||
"major": 0,
|
|
||||||
"minor": 4,
|
|
||||||
"build": 6,
|
|
||||||
},
|
|
||||||
"items_handling": 0,
|
|
||||||
"tags": [],
|
|
||||||
"slot_data": False,
|
|
||||||
}]))
|
|
||||||
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
|
|
||||||
if connect_result_msg["cmd"] != "Connected":
|
|
||||||
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
|
|
||||||
self.missing_locations = connect_result_msg["missing_locations"]
|
|
||||||
self.checked_locations = connect_result_msg["checked_locations"]
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._ws:
|
|
||||||
Thread(target=self._poll).start()
|
|
||||||
self._ws.close()
|
|
||||||
|
|
||||||
def collect(self, locations: Iterable[int]) -> None:
|
|
||||||
if not self._ws:
|
|
||||||
raise ValueError("Not connected")
|
|
||||||
self._ws.send(json.dumps([{
|
|
||||||
"cmd": "LocationChecks",
|
|
||||||
"locations": locations,
|
|
||||||
}]))
|
|
||||||
|
|
||||||
def collect_any(self) -> None:
|
|
||||||
self.collect([next(iter(self.missing_locations))])
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable, Union, TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from multiprocessing.managers import ListProxy # noqa
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"generate_local",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_local_inner(games: Iterable[str],
|
|
||||||
dest: Union[Path, str],
|
|
||||||
results: "ListProxy[Union[Path, BaseException]]") -> None:
|
|
||||||
original_argv = sys.argv
|
|
||||||
warnings.simplefilter("ignore")
|
|
||||||
try:
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
if not isinstance(dest, Path):
|
|
||||||
dest = Path(dest)
|
|
||||||
|
|
||||||
with TemporaryDirectory() as players_dir:
|
|
||||||
with TemporaryDirectory() as output_dir:
|
|
||||||
import Generate
|
|
||||||
import Main
|
|
||||||
|
|
||||||
for n, game in enumerate(games, 1):
|
|
||||||
player_path = Path(players_dir) / f"{n}.yaml"
|
|
||||||
with open(player_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps({
|
|
||||||
"name": f"Player{n}",
|
|
||||||
"game": game,
|
|
||||||
game: {"hard_mode": "true"},
|
|
||||||
"description": f"generate_local slot {n} ('Player{n}'): {game}",
|
|
||||||
}))
|
|
||||||
|
|
||||||
# this is basically copied from test/programs/test_generate.py
|
|
||||||
# uses a reproducible seed that is different for each set of games
|
|
||||||
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
|
|
||||||
"--player_files_path", players_dir,
|
|
||||||
"--outputpath", output_dir]
|
|
||||||
Main.main(*Generate.main())
|
|
||||||
output_files = list(Path(output_dir).glob('*.zip'))
|
|
||||||
assert len(output_files) == 1
|
|
||||||
final_file = dest / output_files[0].name
|
|
||||||
output_files[0].rename(final_file)
|
|
||||||
results.append(final_file)
|
|
||||||
except BaseException as e:
|
|
||||||
results.append(e)
|
|
||||||
raise e
|
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
|
|
||||||
|
|
||||||
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
|
|
||||||
from multiprocessing import Manager, Process, set_start_method
|
|
||||||
|
|
||||||
try:
|
|
||||||
set_start_method("spawn")
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
manager = Manager()
|
|
||||||
results: "ListProxy[Union[Path, Exception]]" = manager.list()
|
|
||||||
|
|
||||||
p = Process(target=_generate_local_inner, args=(games, dest, results))
|
|
||||||
p.start()
|
|
||||||
p.join()
|
|
||||||
result = results[0]
|
|
||||||
if isinstance(result, BaseException):
|
|
||||||
raise Exception("Could not generate multiworld") from result
|
|
||||||
return result
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from threading import Event
|
|
||||||
from werkzeug.test import Client as FlaskClient
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ServeGame",
|
|
||||||
"LocalServeGame",
|
|
||||||
"WebHostServeGame",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ServeGame:
|
|
||||||
address: str
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
|
|
||||||
import os
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
original_argv = sys.argv
|
|
||||||
original_stdin = sys.stdin
|
|
||||||
warnings.simplefilter("ignore")
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
from MultiServer import main, parse_args
|
|
||||||
|
|
||||||
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
|
|
||||||
r, w = os.pipe()
|
|
||||||
sys.stdin = os.fdopen(r, "r")
|
|
||||||
|
|
||||||
async def set_ready() -> None:
|
|
||||||
await asyncio.sleep(.01) # switch back to other task once more
|
|
||||||
ready.set() # server should be up, set ready state
|
|
||||||
|
|
||||||
async def wait_stop() -> None:
|
|
||||||
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
|
|
||||||
os.fdopen(w, "w").write("/exit")
|
|
||||||
|
|
||||||
async def run() -> None:
|
|
||||||
# this will run main() until first await, then switch to set_ready()
|
|
||||||
await asyncio.gather(
|
|
||||||
main(parse_args()),
|
|
||||||
set_ready(),
|
|
||||||
wait_stop(),
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(run())
|
|
||||||
finally:
|
|
||||||
sys.argv = original_argv
|
|
||||||
sys.stdin = original_stdin
|
|
||||||
|
|
||||||
|
|
||||||
class LocalServeGame(ServeGame):
|
|
||||||
from multiprocessing import Process
|
|
||||||
|
|
||||||
_multidata: Path
|
|
||||||
_proc: Process
|
|
||||||
_stop: "Event"
|
|
||||||
|
|
||||||
def __init__(self, multidata: Path) -> None:
|
|
||||||
self.address = ""
|
|
||||||
self._multidata = multidata
|
|
||||||
|
|
||||||
def __enter__(self) -> "LocalServeGame":
|
|
||||||
from multiprocessing import Manager, Process, set_start_method
|
|
||||||
|
|
||||||
try:
|
|
||||||
set_start_method("spawn")
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
manager = Manager()
|
|
||||||
ready: "Event" = manager.Event()
|
|
||||||
self._stop = manager.Event()
|
|
||||||
|
|
||||||
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
|
|
||||||
try:
|
|
||||||
self._proc.start()
|
|
||||||
ready.wait(30)
|
|
||||||
self.address = "localhost:38281"
|
|
||||||
return self
|
|
||||||
except BaseException:
|
|
||||||
self.__exit__(*sys.exc_info())
|
|
||||||
raise
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
|
||||||
try:
|
|
||||||
self._stop.set()
|
|
||||||
self._proc.join(30)
|
|
||||||
except TimeoutError:
|
|
||||||
self._proc.terminate()
|
|
||||||
self._proc.join()
|
|
||||||
|
|
||||||
|
|
||||||
class WebHostServeGame(ServeGame):
|
|
||||||
_client: "FlaskClient"
|
|
||||||
_room: str
|
|
||||||
|
|
||||||
def __init__(self, app_client: "FlaskClient", room: str) -> None:
|
|
||||||
self.address = ""
|
|
||||||
self._client = app_client
|
|
||||||
self._room = room
|
|
||||||
|
|
||||||
def __enter__(self) -> "WebHostServeGame":
|
|
||||||
from .webhost import start_room
|
|
||||||
self.address = start_room(self._client, self._room)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
|
|
||||||
from .webhost import stop_room
|
|
||||||
stop_room(self._client, self._room, timeout=30)
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from flask import Flask
|
|
||||||
from werkzeug.test import Client as FlaskClient
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"get_app",
|
|
||||||
"upload_multidata",
|
|
||||||
"create_room",
|
|
||||||
"start_room",
|
|
||||||
"stop_room",
|
|
||||||
"set_room_timeout",
|
|
||||||
"get_multidata_for_room",
|
|
||||||
"set_multidata_for_room",
|
|
||||||
"stop_autohost",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_app(tempdir: str) -> "Flask":
|
|
||||||
from WebHostLib import app as raw_app
|
|
||||||
from WebHost import get_app
|
|
||||||
raw_app.config["PONY"] = {
|
|
||||||
"provider": "sqlite",
|
|
||||||
"filename": str(Path(tempdir) / "host.db"),
|
|
||||||
"create_db": True,
|
|
||||||
}
|
|
||||||
raw_app.config.update({
|
|
||||||
"TESTING": True,
|
|
||||||
"HOST_ADDRESS": "localhost",
|
|
||||||
"HOSTERS": 1,
|
|
||||||
})
|
|
||||||
return get_app()
|
|
||||||
|
|
||||||
|
|
||||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
|
||||||
response = app_client.post("/uploads", data={
|
|
||||||
"file": multidata.open("rb"),
|
|
||||||
})
|
|
||||||
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
|
|
||||||
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
|
|
||||||
location = response.headers["Location"]
|
|
||||||
assert isinstance(location, str)
|
|
||||||
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
|
|
||||||
return location[6:]
|
|
||||||
|
|
||||||
|
|
||||||
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
|
|
||||||
response = app_client.get(f"/new_room/{seed}")
|
|
||||||
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
|
|
||||||
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
|
|
||||||
location = response.headers["Location"]
|
|
||||||
assert isinstance(location, str)
|
|
||||||
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
|
|
||||||
room_id = location[6:]
|
|
||||||
|
|
||||||
if not auto_start:
|
|
||||||
# by default, creating a room will auto-start it, so we update last activity here
|
|
||||||
stop_room(app_client, room_id, simulate_idle=False)
|
|
||||||
|
|
||||||
return room_id
|
|
||||||
|
|
||||||
|
|
||||||
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pony.orm
|
|
||||||
|
|
||||||
poll_interval = .2
|
|
||||||
|
|
||||||
print(f"Starting room {room_id}")
|
|
||||||
no_timeout = timeout <= 0
|
|
||||||
while no_timeout or timeout > 0:
|
|
||||||
try:
|
|
||||||
response = app_client.get(f"/room/{room_id}")
|
|
||||||
except pony.orm.core.OptimisticCheckError:
|
|
||||||
# hoster wrote to room during our transaction
|
|
||||||
continue
|
|
||||||
|
|
||||||
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
|
|
||||||
match = re.search(r"/connect ([\w:.\-]+)", response.text)
|
|
||||||
if match:
|
|
||||||
return match[1]
|
|
||||||
timeout -= poll_interval
|
|
||||||
sleep(poll_interval)
|
|
||||||
raise TimeoutError("Room did not start")
|
|
||||||
|
|
||||||
|
|
||||||
def stop_room(app_client: "FlaskClient",
|
|
||||||
room_id: str,
|
|
||||||
timeout: Optional[float] = None,
|
|
||||||
simulate_idle: bool = True) -> None:
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
from pony.orm import db_session
|
|
||||||
|
|
||||||
from WebHostLib.models import Command, Room
|
|
||||||
from WebHostLib import app
|
|
||||||
|
|
||||||
poll_interval = 2
|
|
||||||
|
|
||||||
print(f"Stopping room {room_id}")
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
if timeout is not None:
|
|
||||||
sleep(.1) # should not be required, but other things might use threading
|
|
||||||
|
|
||||||
with db_session:
|
|
||||||
room: Room = Room.get(id=room_uuid)
|
|
||||||
if simulate_idle:
|
|
||||||
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
|
|
||||||
else:
|
|
||||||
new_last_activity = datetime.utcnow() - timedelta(days=3)
|
|
||||||
room.last_activity = new_last_activity
|
|
||||||
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
|
|
||||||
if address:
|
|
||||||
original_timeout = room.timeout
|
|
||||||
room.timeout = 1 # avoid spinning it up again
|
|
||||||
Command(room=room, commandtext="/exit")
|
|
||||||
|
|
||||||
try:
|
|
||||||
if address and timeout is not None:
|
|
||||||
print("waiting for shutdown")
|
|
||||||
import socket
|
|
||||||
host_str, port_str = tuple(address.split(":"))
|
|
||||||
address_tuple = host_str, int(port_str)
|
|
||||||
|
|
||||||
no_timeout = timeout <= 0
|
|
||||||
while no_timeout or timeout > 0:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
try:
|
|
||||||
s.connect(address_tuple)
|
|
||||||
s.close()
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
return
|
|
||||||
sleep(poll_interval)
|
|
||||||
timeout -= poll_interval
|
|
||||||
|
|
||||||
raise TimeoutError("Room did not stop")
|
|
||||||
finally:
|
|
||||||
with db_session:
|
|
||||||
room = Room.get(id=room_uuid)
|
|
||||||
room.last_port = 0 # easier to detect when the host is up this way
|
|
||||||
if address:
|
|
||||||
room.timeout = original_timeout
|
|
||||||
room.last_activity = new_last_activity
|
|
||||||
print("timeout restored")
|
|
||||||
|
|
||||||
|
|
||||||
def set_room_timeout(room_id: str, timeout: float) -> None:
|
|
||||||
from pony.orm import db_session
|
|
||||||
|
|
||||||
from WebHostLib.models import Room
|
|
||||||
from WebHostLib import app
|
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
|
||||||
with db_session:
|
|
||||||
room: Room = Room.get(id=room_uuid)
|
|
||||||
room.timeout = timeout
|
|
||||||
|
|
||||||
|
|
||||||
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
|
|
||||||
from pony.orm import db_session
|
|
||||||
|
|
||||||
from WebHostLib.models import Room
|
|
||||||
from WebHostLib import app
|
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
|
||||||
with db_session:
|
|
||||||
room: Room = Room.get(id=room_uuid)
|
|
||||||
return cast(bytes, room.seed.multidata)
|
|
||||||
|
|
||||||
|
|
||||||
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
|
|
||||||
from pony.orm import db_session
|
|
||||||
|
|
||||||
from WebHostLib.models import Room
|
|
||||||
from WebHostLib import app
|
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
|
||||||
with db_session:
|
|
||||||
room: Room = Room.get(id=room_uuid)
|
|
||||||
room.seed.multidata = data
|
|
||||||
|
|
||||||
|
|
||||||
def stop_autohost(graceful: bool = True) -> None:
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
|
|
||||||
import multiprocessing
|
|
||||||
|
|
||||||
from WebHostLib.autolauncher import stop
|
|
||||||
|
|
||||||
stop()
|
|
||||||
proc: multiprocessing.process.BaseProcess
|
|
||||||
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
|
||||||
if graceful and proc.pid:
|
|
||||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
|
||||||
else:
|
|
||||||
proc.kill()
|
|
||||||
try:
|
|
||||||
proc.join(30)
|
|
||||||
except TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
proc.join()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import re
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["copy", "delete"]
|
|
||||||
|
|
||||||
|
|
||||||
_new_worlds: Dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def copy(src: str, dst: str) -> None:
|
|
||||||
from Utils import get_file_safe_name
|
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
|
|
||||||
assert dst not in _new_worlds, "World already created"
|
|
||||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
|
||||||
raise ValueError(f"Unsupported symbols in {dst}")
|
|
||||||
dst_folder_name = get_file_safe_name(dst.lower())
|
|
||||||
src_cls = AutoWorldRegister.world_types[src]
|
|
||||||
src_folder = Path(src_cls.__file__).parent
|
|
||||||
worlds_folder = src_folder.parent
|
|
||||||
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
|
|
||||||
or not (worlds_folder / "generic").is_dir()):
|
|
||||||
raise ValueError(f"Unsupported layout for copy_world from {src}")
|
|
||||||
dst_folder = worlds_folder / dst_folder_name
|
|
||||||
if dst_folder.is_dir():
|
|
||||||
raise ValueError(f"Destination {dst_folder} already exists")
|
|
||||||
shutil.copytree(src_folder, dst_folder)
|
|
||||||
_new_worlds[dst] = str(dst_folder)
|
|
||||||
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
|
||||||
contents = f.read()
|
|
||||||
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
|
||||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
|
||||||
f.write(contents)
|
|
||||||
|
|
||||||
|
|
||||||
def delete(name: str) -> None:
|
|
||||||
assert name in _new_worlds, "World not created by this script"
|
|
||||||
shutil.rmtree(_new_worlds[name])
|
|
||||||
del _new_worlds[name]
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
# Tests for _speedups.LocationStore and NetUtils._LocationStore
|
# Tests for _speedups.LocationStore and NetUtils._LocationStore
|
||||||
import os
|
|
||||||
import typing
|
import typing
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
import warnings
|
||||||
@@ -8,8 +7,6 @@ from NetUtils import LocationStore, _LocationStore
|
|||||||
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
|
|
||||||
ci = bool(os.environ.get("CI")) # always set in GitHub actions
|
|
||||||
|
|
||||||
sample_data: RawLocations = {
|
sample_data: RawLocations = {
|
||||||
1: {
|
1: {
|
||||||
11: (21, 2, 7),
|
11: (21, 2, 7),
|
||||||
@@ -27,9 +24,6 @@ sample_data: RawLocations = {
|
|||||||
3: {
|
3: {
|
||||||
9: (99, 4, 0),
|
9: (99, 4, 0),
|
||||||
},
|
},
|
||||||
5: {
|
|
||||||
9: (99, 5, 0),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
empty_state: State = {
|
empty_state: State = {
|
||||||
@@ -51,14 +45,14 @@ class Base:
|
|||||||
store: typing.Union[LocationStore, _LocationStore]
|
store: typing.Union[LocationStore, _LocationStore]
|
||||||
|
|
||||||
def test_len(self) -> None:
|
def test_len(self) -> None:
|
||||||
self.assertEqual(len(self.store), 5)
|
self.assertEqual(len(self.store), 4)
|
||||||
self.assertEqual(len(self.store[1]), 3)
|
self.assertEqual(len(self.store[1]), 3)
|
||||||
|
|
||||||
def test_key_error(self) -> None:
|
def test_key_error(self) -> None:
|
||||||
with self.assertRaises(KeyError):
|
with self.assertRaises(KeyError):
|
||||||
_ = self.store[0]
|
_ = self.store[0]
|
||||||
with self.assertRaises(KeyError):
|
with self.assertRaises(KeyError):
|
||||||
_ = self.store[6]
|
_ = self.store[5]
|
||||||
locations = self.store[1] # no Exception
|
locations = self.store[1] # no Exception
|
||||||
with self.assertRaises(KeyError):
|
with self.assertRaises(KeyError):
|
||||||
_ = locations[7]
|
_ = locations[7]
|
||||||
@@ -77,7 +71,7 @@ class Base:
|
|||||||
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
|
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
|
||||||
|
|
||||||
def test_iter(self) -> None:
|
def test_iter(self) -> None:
|
||||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
|
self.assertEqual(sorted(self.store), [1, 2, 3, 4])
|
||||||
self.assertEqual(len(self.store), len(sample_data))
|
self.assertEqual(len(self.store), len(sample_data))
|
||||||
self.assertEqual(list(self.store[1]), [11, 12, 13])
|
self.assertEqual(list(self.store[1]), [11, 12, 13])
|
||||||
self.assertEqual(len(self.store[1]), len(sample_data[1]))
|
self.assertEqual(len(self.store[1]), len(sample_data[1]))
|
||||||
@@ -91,26 +85,13 @@ class Base:
|
|||||||
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
|
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
|
||||||
|
|
||||||
def test_find_item(self) -> None:
|
def test_find_item(self) -> None:
|
||||||
# empty player set
|
|
||||||
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
|
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
|
||||||
# no such player, single
|
|
||||||
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
|
|
||||||
# no such player, set
|
|
||||||
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
|
|
||||||
# no such item
|
|
||||||
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
|
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
|
||||||
# valid matches
|
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
|
||||||
self.assertEqual(sorted(self.store.find_item({3}, 99)),
|
self.assertEqual(sorted(self.store.find_item({3}, 99)),
|
||||||
[(4, 9, 99, 3, 0)])
|
[(4, 9, 99, 3, 0)])
|
||||||
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
|
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
|
||||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
||||||
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
|
|
||||||
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
|
|
||||||
# test hash collision in set
|
|
||||||
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
|
|
||||||
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
|
|
||||||
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
|
|
||||||
[(1, 13, 13, 1, 0)])
|
|
||||||
|
|
||||||
def test_get_for_player(self) -> None:
|
def test_get_for_player(self) -> None:
|
||||||
self.assertEqual(self.store.get_for_player(3), {4: {9}})
|
self.assertEqual(self.store.get_for_player(3), {4: {9}})
|
||||||
@@ -215,20 +196,18 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||||
class TestSpeedupsLocationStore(Base.TestLocationStore):
|
class TestSpeedupsLocationStore(Base.TestLocationStore):
|
||||||
"""Run base method tests for cython implementation."""
|
"""Run base method tests for cython implementation."""
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
|
||||||
self.store = LocationStore(sample_data)
|
self.store = LocationStore(sample_data)
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
|
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||||
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||||
"""Run base constructor tests and tests the additional constraints for cython implementation."""
|
"""Run base constructor tests and tests the additional constraints for cython implementation."""
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
|
|
||||||
self.type = LocationStore
|
self.type = LocationStore
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from Options import Choice, DefaultOnToggle, Toggle
|
|
||||||
|
|
||||||
|
|
||||||
class TestNumericOptions(unittest.TestCase):
|
|
||||||
def test_numeric_option(self) -> None:
|
|
||||||
"""Tests the initialization and equivalency comparisons of the base Numeric Option class."""
|
|
||||||
class TestChoice(Choice):
|
|
||||||
option_zero = 0
|
|
||||||
option_one = 1
|
|
||||||
option_two = 2
|
|
||||||
alias_three = 1
|
|
||||||
non_option_attr = 2
|
|
||||||
|
|
||||||
class TestToggle(Toggle):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TestDefaultOnToggle(DefaultOnToggle):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with self.subTest("choice"):
|
|
||||||
choice_option_default = TestChoice.from_any(TestChoice.default)
|
|
||||||
choice_option_string = TestChoice.from_any("one")
|
|
||||||
choice_option_int = TestChoice.from_any(2)
|
|
||||||
choice_option_alias = TestChoice.from_any("three")
|
|
||||||
choice_option_attr = TestChoice.from_any(TestChoice.option_two)
|
|
||||||
|
|
||||||
self.assertEqual(choice_option_default, TestChoice.option_zero,
|
|
||||||
"assigning default didn't match default value")
|
|
||||||
self.assertEqual(choice_option_string, "one")
|
|
||||||
self.assertEqual(choice_option_int, 2)
|
|
||||||
self.assertEqual(choice_option_alias, TestChoice.alias_three)
|
|
||||||
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
|
|
||||||
|
|
||||||
self.assertRaises(KeyError, TestChoice.from_any, "four")
|
|
||||||
|
|
||||||
self.assertIn(choice_option_int, [1, 2, 3])
|
|
||||||
self.assertIn(choice_option_int, {2})
|
|
||||||
self.assertIn(choice_option_int, (2,))
|
|
||||||
|
|
||||||
self.assertIn(choice_option_string, ["one", "two", "three"])
|
|
||||||
# this fails since the hash is derived from the value
|
|
||||||
self.assertNotIn(choice_option_string, {"one"})
|
|
||||||
self.assertIn(choice_option_string, ("one",))
|
|
||||||
|
|
||||||
with self.subTest("toggle"):
|
|
||||||
toggle_default = TestToggle.from_any(TestToggle.default)
|
|
||||||
toggle_string = TestToggle.from_any("false")
|
|
||||||
toggle_int = TestToggle.from_any(0)
|
|
||||||
toggle_alias = TestToggle.from_any("off")
|
|
||||||
|
|
||||||
self.assertFalse(toggle_default)
|
|
||||||
self.assertFalse(toggle_string)
|
|
||||||
self.assertFalse(toggle_int)
|
|
||||||
self.assertFalse(toggle_alias)
|
|
||||||
|
|
||||||
with self.subTest("on toggle"):
|
|
||||||
toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default)
|
|
||||||
toggle_string = TestDefaultOnToggle.from_any("true")
|
|
||||||
toggle_int = TestDefaultOnToggle.from_any(1)
|
|
||||||
toggle_alias = TestDefaultOnToggle.from_any("on")
|
|
||||||
|
|
||||||
self.assertTrue(toggle_default)
|
|
||||||
self.assertTrue(toggle_string)
|
|
||||||
self.assertTrue(toggle_int)
|
|
||||||
self.assertTrue(toggle_alias)
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
import NetUtils
|
|
||||||
from CommonClient import CommonContext
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommonContext(unittest.IsolatedAsyncioTestCase):
|
|
||||||
async def asyncSetUp(self):
|
|
||||||
self.ctx = CommonContext()
|
|
||||||
self.ctx.slot = 1 # Pretend we're player 1 for this.
|
|
||||||
self.ctx.slot_info.update({
|
|
||||||
1: NetUtils.NetworkSlot("Player 1", "__TestGame1", NetUtils.SlotType.player),
|
|
||||||
2: NetUtils.NetworkSlot("Player 2", "__TestGame1", NetUtils.SlotType.player),
|
|
||||||
3: NetUtils.NetworkSlot("Player 3", "__TestGame2", NetUtils.SlotType.player),
|
|
||||||
})
|
|
||||||
self.ctx.consume_players_package([
|
|
||||||
NetUtils.NetworkPlayer(1, 1, "Player 1", "Player 1"),
|
|
||||||
NetUtils.NetworkPlayer(1, 2, "Player 2", "Player 2"),
|
|
||||||
NetUtils.NetworkPlayer(1, 3, "Player 3", "Player 3"),
|
|
||||||
])
|
|
||||||
# Using IDs outside the "safe range" for testing purposes only. If this fails unit tests, it's because
|
|
||||||
# another world is not following the spec for allowed ID ranges.
|
|
||||||
self.ctx.update_data_package({
|
|
||||||
"games": {
|
|
||||||
"__TestGame1": {
|
|
||||||
"location_name_to_id": {
|
|
||||||
"Test Location 1 - Safe": 2**54 + 1,
|
|
||||||
"Test Location 2 - Duplicate": 2**54 + 2,
|
|
||||||
},
|
|
||||||
"item_name_to_id": {
|
|
||||||
"Test Item 1 - Safe": 2**54 + 1,
|
|
||||||
"Test Item 2 - Duplicate": 2**54 + 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"__TestGame2": {
|
|
||||||
"location_name_to_id": {
|
|
||||||
"Test Location 3 - Duplicate": 2**54 + 2,
|
|
||||||
},
|
|
||||||
"item_name_to_id": {
|
|
||||||
"Test Item 3 - Duplicate": 2**54 + 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async def test_archipelago_datapackage_lookups_exist(self):
|
|
||||||
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
|
|
||||||
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
|
|
||||||
|
|
||||||
async def test_implicit_name_lookups(self):
|
|
||||||
# Items
|
|
||||||
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
|
|
||||||
assert self.ctx.item_names[-1] == "Nothing"
|
|
||||||
|
|
||||||
# Locations
|
|
||||||
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
|
|
||||||
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
|
|
||||||
assert self.ctx.location_names[-1] == "Cheat Console"
|
|
||||||
|
|
||||||
async def test_explicit_name_lookups(self):
|
|
||||||
# Items
|
|
||||||
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names["__TestGame1"][2**54+2] == "Test Item 2 - Duplicate"
|
|
||||||
assert self.ctx.item_names["__TestGame1"][2**54+3] == f"Unknown item (ID: {2**54+3})"
|
|
||||||
assert self.ctx.item_names["__TestGame1"][-1] == "Nothing"
|
|
||||||
assert self.ctx.item_names["__TestGame2"][2**54+1] == f"Unknown item (ID: {2**54+1})"
|
|
||||||
assert self.ctx.item_names["__TestGame2"][2**54+2] == "Test Item 3 - Duplicate"
|
|
||||||
assert self.ctx.item_names["__TestGame2"][2**54+3] == f"Unknown item (ID: {2**54+3})"
|
|
||||||
assert self.ctx.item_names["__TestGame2"][-1] == "Nothing"
|
|
||||||
|
|
||||||
# Locations
|
|
||||||
assert self.ctx.location_names["__TestGame1"][2**54+1] == "Test Location 1 - Safe"
|
|
||||||
assert self.ctx.location_names["__TestGame1"][2**54+2] == "Test Location 2 - Duplicate"
|
|
||||||
assert self.ctx.location_names["__TestGame1"][2**54+3] == f"Unknown location (ID: {2**54+3})"
|
|
||||||
assert self.ctx.location_names["__TestGame1"][-1] == "Cheat Console"
|
|
||||||
assert self.ctx.location_names["__TestGame2"][2**54+1] == f"Unknown location (ID: {2**54+1})"
|
|
||||||
assert self.ctx.location_names["__TestGame2"][2**54+2] == "Test Location 3 - Duplicate"
|
|
||||||
assert self.ctx.location_names["__TestGame2"][2**54+3] == f"Unknown location (ID: {2**54+3})"
|
|
||||||
assert self.ctx.location_names["__TestGame2"][-1] == "Cheat Console"
|
|
||||||
|
|
||||||
async def test_lookup_helper_functions(self):
|
|
||||||
# Checking own slot.
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1) == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2) == "Test Item 2 - Duplicate"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 3) == f"Unknown item (ID: {2 ** 54 + 3})"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(-1) == f"Nothing"
|
|
||||||
|
|
||||||
# Checking others' slots.
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 2) == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 2) == "Test Item 2 - Duplicate"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 3) == f"Unknown item (ID: {2 ** 54 + 1})"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 3) == "Test Item 3 - Duplicate"
|
|
||||||
|
|
||||||
# Checking by game.
|
|
||||||
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame1") == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame1") == "Test Item 2 - Duplicate"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 3, "__TestGame1") == f"Unknown item (ID: {2 ** 54 + 3})"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame2") == f"Unknown item (ID: {2 ** 54 + 1})"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame2") == "Test Item 3 - Duplicate"
|
|
||||||
|
|
||||||
# Checking with Archipelago ids are valid in any game package.
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(-1, 2) == "Nothing"
|
|
||||||
assert self.ctx.item_names.lookup_in_slot(-1, 3) == "Nothing"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame1") == "Nothing"
|
|
||||||
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame2") == "Nothing"
|
|
||||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
import Generate
|
import Generate
|
||||||
import Main
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateMain(unittest.TestCase):
|
class TestGenerateMain(unittest.TestCase):
|
||||||
@@ -59,7 +58,7 @@ class TestGenerateMain(unittest.TestCase):
|
|||||||
'--player_files_path', str(self.abs_input_dir),
|
'--player_files_path', str(self.abs_input_dir),
|
||||||
'--outputpath', self.output_tempdir.name]
|
'--outputpath', self.output_tempdir.name]
|
||||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
||||||
Main.main(*Generate.main())
|
Generate.main()
|
||||||
|
|
||||||
self.assertOutput(self.output_tempdir.name)
|
self.assertOutput(self.output_tempdir.name)
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ class TestGenerateMain(unittest.TestCase):
|
|||||||
'--player_files_path', str(self.rel_input_dir),
|
'--player_files_path', str(self.rel_input_dir),
|
||||||
'--outputpath', self.output_tempdir.name]
|
'--outputpath', self.output_tempdir.name]
|
||||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
|
||||||
Main.main(*Generate.main())
|
Generate.main()
|
||||||
|
|
||||||
self.assertOutput(self.output_tempdir.name)
|
self.assertOutput(self.output_tempdir.name)
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ class TestGenerateMain(unittest.TestCase):
|
|||||||
sys.argv = [sys.argv[0], '--seed', '0',
|
sys.argv = [sys.argv[0], '--seed', '0',
|
||||||
'--outputpath', self.output_tempdir.name]
|
'--outputpath', self.output_tempdir.name]
|
||||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
|
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
|
||||||
Main.main(*Generate.main())
|
Generate.main()
|
||||||
finally:
|
finally:
|
||||||
user_path.cached_path = user_path_backup
|
user_path.cached_path = user_path_backup
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
|
from Options import Choice, NamedRange, Toggle, Range
|
||||||
|
|
||||||
|
|
||||||
class TestOptionPresets(unittest.TestCase):
|
class TestOptionPresets(unittest.TestCase):
|
||||||
@@ -14,7 +14,7 @@ class TestOptionPresets(unittest.TestCase):
|
|||||||
with self.subTest(game=game_name, preset=preset_name, option=option_name):
|
with self.subTest(game=game_name, preset=preset_name, option=option_name):
|
||||||
try:
|
try:
|
||||||
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
|
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
|
||||||
supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
|
supported_types = [Choice, Toggle, Range, NamedRange]
|
||||||
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
||||||
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
||||||
f"is not a supported type for webhost. "
|
f"is not a supported type for webhost. "
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from typing import Callable, ClassVar
|
|
||||||
|
|
||||||
from kivy.event import EventDispatcher
|
|
||||||
|
|
||||||
|
|
||||||
class WindowBase(EventDispatcher):
|
|
||||||
width: ClassVar[int] # readonly AliasProperty
|
|
||||||
height: ClassVar[int] # readonly AliasProperty
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def bind(**kwargs: Callable[..., None]) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Window(WindowBase):
|
|
||||||
...
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
class EventDispatcher:
|
|
||||||
...
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from typing import Literal
|
|
||||||
from .layout import Layout
|
|
||||||
|
|
||||||
|
|
||||||
class BoxLayout(Layout):
|
|
||||||
orientation: Literal['horizontal', 'vertical']
|
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
from typing import Any, Sequence
|
from typing import Any
|
||||||
|
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
|
|
||||||
class Layout(Widget):
|
class Layout(Widget):
|
||||||
@property
|
|
||||||
def children(self) -> Sequence[Widget]: ...
|
|
||||||
|
|
||||||
def add_widget(self, widget: Widget) -> None: ...
|
def add_widget(self, widget: Widget) -> None: ...
|
||||||
|
|
||||||
def remove_widget(self, widget: Widget) -> None: ...
|
|
||||||
|
|
||||||
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...
|
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
|
|
||||||
class And:
|
|
||||||
def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Or:
|
|
||||||
def __init__(self, *args: object) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Schema:
|
|
||||||
def __init__(self, __x: object) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Optional(Schema):
|
|
||||||
...
|
|
||||||
@@ -123,8 +123,8 @@ class WebWorldRegister(type):
|
|||||||
assert group.options, "A custom defined Option Group must contain at least one Option."
|
assert group.options, "A custom defined Option Group must contain at least one Option."
|
||||||
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
|
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
|
||||||
title_name = group.name.title()
|
title_name = group.name.title()
|
||||||
assert title_name not in prebuilt_options or title_name == group.name, \
|
if title_name in prebuilt_options:
|
||||||
f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
|
group.name = title_name
|
||||||
|
|
||||||
if group.name == "Item & Location Options":
|
if group.name == "Item & Location Options":
|
||||||
assert not any(option in item_and_loc_options for option in group.options), \
|
assert not any(option in item_and_loc_options for option in group.options), \
|
||||||
@@ -223,21 +223,6 @@ class WebWorld(metaclass=WebWorldRegister):
|
|||||||
option_groups: ClassVar[List[OptionGroup]] = []
|
option_groups: ClassVar[List[OptionGroup]] = []
|
||||||
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
|
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
|
||||||
|
|
||||||
rich_text_options_doc = False
|
|
||||||
"""Whether the WebHost should render Options' docstrings as rich text.
|
|
||||||
|
|
||||||
If this is True, Options' docstrings are interpreted as reStructuredText_,
|
|
||||||
the standard Python markup format. In the WebHost, they're rendered to HTML
|
|
||||||
so that lists, emphasis, and other rich text features are displayed
|
|
||||||
properly.
|
|
||||||
|
|
||||||
If this is False, the docstrings are instead interpreted as plain text, and
|
|
||||||
displayed as-is on the WebHost with whitespace preserved. For backwards
|
|
||||||
compatibility, this is the default.
|
|
||||||
|
|
||||||
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
location_descriptions: Dict[str, str] = {}
|
location_descriptions: Dict[str, str] = {}
|
||||||
"""An optional map from location names (or location group names) to brief descriptions for users."""
|
"""An optional map from location names (or location group names) to brief descriptions for users."""
|
||||||
|
|
||||||
@@ -273,6 +258,18 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
|
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
|
||||||
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
|
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
|
||||||
|
|
||||||
|
data_version: ClassVar[int] = 0
|
||||||
|
"""
|
||||||
|
Increment this every time something in your world's names/id mappings changes.
|
||||||
|
|
||||||
|
When this is set to 0, that world's DataPackage is considered in "testing mode", which signals to servers/clients
|
||||||
|
that it should not be cached, and clients should request that world's DataPackage every connection. Not
|
||||||
|
recommended for production-ready worlds.
|
||||||
|
|
||||||
|
Deprecated. Clients should utilize `checksum` to determine if DataPackage has changed since last connection and
|
||||||
|
request a new DataPackage, if necessary.
|
||||||
|
"""
|
||||||
|
|
||||||
required_client_version: Tuple[int, int, int] = (0, 1, 6)
|
required_client_version: Tuple[int, int, int] = (0, 1, 6)
|
||||||
"""
|
"""
|
||||||
override this if changes to a world break forward-compatibility of the client
|
override this if changes to a world break forward-compatibility of the client
|
||||||
@@ -280,7 +277,7 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
future. Protocol level compatibility check moved to MultiServer.min_client_version.
|
future. Protocol level compatibility check moved to MultiServer.min_client_version.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
required_server_version: Tuple[int, int, int] = (0, 5, 0)
|
required_server_version: Tuple[int, int, int] = (0, 2, 4)
|
||||||
"""update this if the resulting multidata breaks forward-compatibility of the server"""
|
"""update this if the resulting multidata breaks forward-compatibility of the server"""
|
||||||
|
|
||||||
hint_blacklist: ClassVar[FrozenSet[str]] = frozenset()
|
hint_blacklist: ClassVar[FrozenSet[str]] = frozenset()
|
||||||
@@ -546,6 +543,7 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
"item_name_to_id": cls.item_name_to_id,
|
"item_name_to_id": cls.item_name_to_id,
|
||||||
"location_name_groups": sorted_location_name_groups,
|
"location_name_groups": sorted_location_name_groups,
|
||||||
"location_name_to_id": cls.location_name_to_id,
|
"location_name_to_id": cls.location_name_to_id,
|
||||||
|
"version": cls.data_version,
|
||||||
}
|
}
|
||||||
res["checksum"] = data_package_checksum(res)
|
res["checksum"] = data_package_checksum(res)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import bisect
|
|
||||||
import logging
|
|
||||||
import pathlib
|
|
||||||
import weakref
|
import weakref
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Optional, Callable, List, Iterable, Tuple
|
from typing import Optional, Callable, List, Iterable
|
||||||
|
|
||||||
from Utils import local_path, open_filename
|
from Utils import local_path
|
||||||
|
|
||||||
|
|
||||||
class Type(Enum):
|
class Type(Enum):
|
||||||
@@ -52,10 +49,8 @@ class Component:
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"{self.__class__.__name__}({self.display_name})"
|
return f"{self.__class__.__name__}({self.display_name})"
|
||||||
|
|
||||||
|
|
||||||
processes = weakref.WeakSet()
|
processes = weakref.WeakSet()
|
||||||
|
|
||||||
|
|
||||||
def launch_subprocess(func: Callable, name: str = None):
|
def launch_subprocess(func: Callable, name: str = None):
|
||||||
global processes
|
global processes
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
@@ -63,7 +58,6 @@ def launch_subprocess(func: Callable, name: str = None):
|
|||||||
process.start()
|
process.start()
|
||||||
processes.add(process)
|
processes.add(process)
|
||||||
|
|
||||||
|
|
||||||
class SuffixIdentifier:
|
class SuffixIdentifier:
|
||||||
suffixes: Iterable[str]
|
suffixes: Iterable[str]
|
||||||
|
|
||||||
@@ -83,80 +77,6 @@ def launch_textclient():
|
|||||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
|
||||||
|
|
||||||
|
|
||||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
|
||||||
if not apworld_src:
|
|
||||||
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
|
|
||||||
if not apworld_src:
|
|
||||||
# user closed menu
|
|
||||||
return
|
|
||||||
|
|
||||||
if not apworld_src.endswith(".apworld"):
|
|
||||||
raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}")
|
|
||||||
|
|
||||||
apworld_path = pathlib.Path(apworld_src)
|
|
||||||
|
|
||||||
module_name = pathlib.Path(apworld_path.name).stem
|
|
||||||
try:
|
|
||||||
import zipfile
|
|
||||||
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
|
|
||||||
except ValueError as e:
|
|
||||||
raise Exception("Archive appears invalid or damaged.") from e
|
|
||||||
except KeyError as e:
|
|
||||||
raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e
|
|
||||||
|
|
||||||
import worlds
|
|
||||||
if worlds.user_folder is None:
|
|
||||||
raise Exception("Custom Worlds directory appears to not be writable.")
|
|
||||||
for world_source in worlds.world_sources:
|
|
||||||
if apworld_path.samefile(world_source.resolved_path):
|
|
||||||
# Note that this doesn't check if the same world is already installed.
|
|
||||||
# It only checks if the user is trying to install the apworld file
|
|
||||||
# that comes from the installation location (worlds or custom_worlds)
|
|
||||||
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
|
|
||||||
|
|
||||||
# TODO: run generic test suite over the apworld.
|
|
||||||
# TODO: have some kind of version system to tell from metadata if the apworld should be compatible.
|
|
||||||
|
|
||||||
target = pathlib.Path(worlds.user_folder) / apworld_path.name
|
|
||||||
import shutil
|
|
||||||
shutil.copyfile(apworld_path, target)
|
|
||||||
|
|
||||||
# If a module with this name is already loaded, then we can't load it now.
|
|
||||||
# TODO: We need to be able to unload a world module,
|
|
||||||
# so the user can update a world without restarting the application.
|
|
||||||
found_already_loaded = False
|
|
||||||
for loaded_world in worlds.world_sources:
|
|
||||||
loaded_name = pathlib.Path(loaded_world.path).stem
|
|
||||||
if module_name == loaded_name:
|
|
||||||
found_already_loaded = True
|
|
||||||
break
|
|
||||||
if found_already_loaded:
|
|
||||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
|
||||||
"so a Launcher restart is required to use the new installation.")
|
|
||||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
|
||||||
bisect.insort(worlds.world_sources, world_source)
|
|
||||||
world_source.load()
|
|
||||||
|
|
||||||
return apworld_path, target
|
|
||||||
|
|
||||||
|
|
||||||
def install_apworld(apworld_path: str = "") -> None:
|
|
||||||
try:
|
|
||||||
res = _install_apworld(apworld_path)
|
|
||||||
if res is None:
|
|
||||||
logging.info("Aborting APWorld installation.")
|
|
||||||
return
|
|
||||||
source, target = res
|
|
||||||
except Exception as e:
|
|
||||||
import Utils
|
|
||||||
Utils.messagebox(e.__class__.__name__, str(e), error=True)
|
|
||||||
logging.exception(e)
|
|
||||||
else:
|
|
||||||
import Utils
|
|
||||||
logging.info(f"Installed APWorld successfully, copied {source} to {target}.")
|
|
||||||
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
|
|
||||||
|
|
||||||
|
|
||||||
components: List[Component] = [
|
components: List[Component] = [
|
||||||
# Launcher
|
# Launcher
|
||||||
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
||||||
@@ -164,7 +84,6 @@ components: List[Component] = [
|
|||||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||||
Component('Generate', 'Generate', cli=True),
|
Component('Generate', 'Generate', cli=True),
|
||||||
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
|
|
||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
|
||||||
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
||||||
file_identifier=SuffixIdentifier('.apladx')),
|
file_identifier=SuffixIdentifier('.apladx')),
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
import zipimport
|
import zipimport
|
||||||
import time
|
import time
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from typing import Dict, List, TypedDict
|
from typing import Dict, List, TypedDict, Optional
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
|
|
||||||
local_folder = os.path.dirname(__file__)
|
local_folder = os.path.dirname(__file__)
|
||||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
user_folder = user_path("worlds") if user_path() != local_path() else None
|
||||||
try:
|
|
||||||
os.makedirs(user_folder, exist_ok=True)
|
|
||||||
except OSError: # can't access/write?
|
|
||||||
user_folder = None
|
|
||||||
|
|
||||||
__all__ = {
|
__all__ = {
|
||||||
"network_data_package",
|
"network_data_package",
|
||||||
@@ -39,6 +33,7 @@ class GamesPackage(TypedDict, total=False):
|
|||||||
location_name_groups: Dict[str, List[str]]
|
location_name_groups: Dict[str, List[str]]
|
||||||
location_name_to_id: Dict[str, int]
|
location_name_to_id: Dict[str, int]
|
||||||
checksum: str
|
checksum: str
|
||||||
|
version: int # TODO: Remove support after per game data packages API change.
|
||||||
|
|
||||||
|
|
||||||
class DataPackage(TypedDict):
|
class DataPackage(TypedDict):
|
||||||
@@ -50,7 +45,7 @@ class WorldSource:
|
|||||||
path: str # typically relative path from this module
|
path: str # typically relative path from this module
|
||||||
is_zip: bool = False
|
is_zip: bool = False
|
||||||
relative: bool = True # relative to regular world import folder
|
relative: bool = True # relative to regular world import folder
|
||||||
time_taken: float = -1.0
|
time_taken: Optional[float] = None
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
||||||
@@ -94,6 +89,7 @@ class WorldSource:
|
|||||||
print(f"Could not load world {self}:", file=file_like)
|
print(f"Could not load world {self}:", file=file_like)
|
||||||
traceback.print_exc(file=file_like)
|
traceback.print_exc(file=file_like)
|
||||||
file_like.seek(0)
|
file_like.seek(0)
|
||||||
|
import logging
|
||||||
logging.exception(file_like.read())
|
logging.exception(file_like.read())
|
||||||
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
|
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||||
return False
|
return False
|
||||||
@@ -108,12 +104,7 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
|
|||||||
if not entry.name.startswith(("_", ".")):
|
if not entry.name.startswith(("_", ".")):
|
||||||
file_name = entry.name if relative else os.path.join(folder, entry.name)
|
file_name = entry.name if relative else os.path.join(folder, entry.name)
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
if os.path.isfile(os.path.join(entry.path, '__init__.py')):
|
world_sources.append(WorldSource(file_name, relative=relative))
|
||||||
world_sources.append(WorldSource(file_name, relative=relative))
|
|
||||||
elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')):
|
|
||||||
world_sources.append(WorldSource(file_name, relative=relative))
|
|
||||||
else:
|
|
||||||
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
|
|
||||||
elif entry.is_file() and entry.name.endswith(".apworld"):
|
elif entry.is_file() and entry.name.endswith(".apworld"):
|
||||||
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
|
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
|
||||||
|
|
||||||
@@ -128,4 +119,3 @@ from .AutoWorld import AutoWorldRegister
|
|||||||
network_data_package: DataPackage = {
|
network_data_package: DataPackage = {
|
||||||
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ async def _game_watcher(ctx: BizHawkClientContext):
|
|||||||
ctx.auth = None
|
ctx.auth = None
|
||||||
ctx.username = None
|
ctx.username = None
|
||||||
ctx.client_handler = None
|
ctx.client_handler = None
|
||||||
ctx.finished_game = False
|
|
||||||
await ctx.disconnect(False)
|
await ctx.disconnect(False)
|
||||||
ctx.rom_hash = rom_hash
|
ctx.rom_hash = rom_hash
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ class kill_switch:
|
|||||||
logger.debug("kill_switch: Add switch")
|
logger.debug("kill_switch: Add switch")
|
||||||
cls._to_kill.append(value)
|
cls._to_kill.append(value)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def kill(cls, value):
|
|
||||||
logger.info(f"kill_switch: Process cleanup for 1 process")
|
|
||||||
value._clean(verbose=False)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def kill_all(cls):
|
def kill_all(cls):
|
||||||
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
|
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
|
||||||
@@ -121,7 +116,7 @@ class SC2Process:
|
|||||||
async def __aexit__(self, *args):
|
async def __aexit__(self, *args):
|
||||||
logger.exception("async exit")
|
logger.exception("async exit")
|
||||||
await self._close_connection()
|
await self._close_connection()
|
||||||
kill_switch.kill(self)
|
kill_switch.kill_all()
|
||||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
|
||||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
|
||||||
|
|
||||||
|
|
||||||
class FreeincarnateMax(Range):
|
class FreeincarnateMax(Range):
|
||||||
@@ -224,22 +223,22 @@ class StartCastle(Choice):
|
|||||||
option_white = 2
|
option_white = 2
|
||||||
default = option_yellow
|
default = option_yellow
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdventureOptions(PerGameCommonOptions):
|
|
||||||
dragon_slay_check: DragonSlayCheck
|
|
||||||
death_link: DeathLink
|
|
||||||
bat_logic: BatLogic
|
|
||||||
freeincarnate_max: FreeincarnateMax
|
|
||||||
dragon_rando_type: DragonRandoType
|
|
||||||
connector_multi_slot: ConnectorMultiSlot
|
|
||||||
yorgle_speed: YorgleStartingSpeed
|
|
||||||
yorgle_min_speed: YorgleMinimumSpeed
|
|
||||||
grundle_speed: GrundleStartingSpeed
|
|
||||||
grundle_min_speed: GrundleMinimumSpeed
|
|
||||||
rhindle_speed: RhindleStartingSpeed
|
|
||||||
rhindle_min_speed: RhindleMinimumSpeed
|
|
||||||
difficulty_switch_a: DifficultySwitchA
|
|
||||||
difficulty_switch_b: DifficultySwitchB
|
|
||||||
start_castle: StartCastle
|
|
||||||
|
|
||||||
|
adventure_option_definitions: Dict[str, type(Option)] = {
|
||||||
|
"dragon_slay_check": DragonSlayCheck,
|
||||||
|
"death_link": DeathLink,
|
||||||
|
"bat_logic": BatLogic,
|
||||||
|
"freeincarnate_max": FreeincarnateMax,
|
||||||
|
"dragon_rando_type": DragonRandoType,
|
||||||
|
"connector_multi_slot": ConnectorMultiSlot,
|
||||||
|
"yorgle_speed": YorgleStartingSpeed,
|
||||||
|
"yorgle_min_speed": YorgleMinimumSpeed,
|
||||||
|
"grundle_speed": GrundleStartingSpeed,
|
||||||
|
"grundle_min_speed": GrundleMinimumSpeed,
|
||||||
|
"rhindle_speed": RhindleStartingSpeed,
|
||||||
|
"rhindle_min_speed": RhindleMinimumSpeed,
|
||||||
|
"difficulty_switch_a": DifficultySwitchA,
|
||||||
|
"difficulty_switch_b": DifficultySwitchB,
|
||||||
|
"start_castle": StartCastle,
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||||
from Options import PerGameCommonOptions
|
|
||||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
|
|||||||
connect(world, player, target, source, rule, True)
|
connect(world, player, target, source, rule, True)
|
||||||
|
|
||||||
|
|
||||||
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
||||||
|
|
||||||
menu = Region("Menu", player, multiworld)
|
menu = Region("Menu", player, multiworld)
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
|
|||||||
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
|
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
|
||||||
multiworld.regions.append(credits_room_far_side)
|
multiworld.regions.append(credits_room_far_side)
|
||||||
|
|
||||||
dragon_slay_check = options.dragon_slay_check.value
|
dragon_slay_check = multiworld.dragon_slay_check[player].value
|
||||||
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
|
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
|
||||||
|
|
||||||
for name, location_data in location_table.items():
|
for name, location_data in location_table.items():
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from .Options import BatLogic, DifficultySwitchB
|
from worlds.adventure import location_table
|
||||||
|
from worlds.adventure.Options import BatLogic, DifficultySwitchB, DifficultySwitchA
|
||||||
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
||||||
|
from BaseClasses import LocationProgressType
|
||||||
|
|
||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
world = self.multiworld
|
world = self.multiworld
|
||||||
use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
|
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
|
||||||
|
|
||||||
set_rule(world.get_entrance("YellowCastlePort", self.player),
|
set_rule(world.get_entrance("YellowCastlePort", self.player),
|
||||||
lambda state: state.has("Yellow Key", self.player))
|
lambda state: state.has("Yellow Key", self.player))
|
||||||
@@ -26,7 +28,7 @@ def set_rules(self) -> None:
|
|||||||
lambda state: state.has("Bridge", self.player) or
|
lambda state: state.has("Bridge", self.player) or
|
||||||
state.has("Magnet", self.player))
|
state.has("Magnet", self.player))
|
||||||
|
|
||||||
dragon_slay_check = self.options.dragon_slay_check.value
|
dragon_slay_check = world.dragon_slay_check[self.player].value
|
||||||
if dragon_slay_check:
|
if dragon_slay_check:
|
||||||
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
|
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
|
||||||
set_rule(world.get_location("Slay Yorgle", self.player),
|
set_rule(world.get_location("Slay Yorgle", self.player),
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ from Options import AssembleOptions
|
|||||||
from worlds.AutoWorld import WebWorld, World
|
from worlds.AutoWorld import WebWorld, World
|
||||||
from Fill import fill_restrictive
|
from Fill import fill_restrictive
|
||||||
from worlds.generic.Rules import add_rule, set_rule
|
from worlds.generic.Rules import add_rule, set_rule
|
||||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
|
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
|
||||||
AdventureOptions
|
|
||||||
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
|
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
|
||||||
AdventureAutoCollectLocation
|
AdventureAutoCollectLocation
|
||||||
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
||||||
@@ -110,10 +109,11 @@ class AdventureWorld(World):
|
|||||||
game: ClassVar[str] = "Adventure"
|
game: ClassVar[str] = "Adventure"
|
||||||
web: ClassVar[WebWorld] = AdventureWeb()
|
web: ClassVar[WebWorld] = AdventureWeb()
|
||||||
|
|
||||||
options_dataclass = AdventureOptions
|
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
|
||||||
settings: ClassVar[AdventureSettings]
|
settings: ClassVar[AdventureSettings]
|
||||||
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
|
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
|
||||||
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
|
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
|
||||||
|
data_version: ClassVar[int] = 1
|
||||||
required_client_version: Tuple[int, int, int] = (0, 3, 9)
|
required_client_version: Tuple[int, int, int] = (0, 3, 9)
|
||||||
|
|
||||||
def __init__(self, world: MultiWorld, player: int):
|
def __init__(self, world: MultiWorld, player: int):
|
||||||
@@ -150,18 +150,18 @@ class AdventureWorld(World):
|
|||||||
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
|
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
|
||||||
self.rom_name.extend([0] * (21 - len(self.rom_name)))
|
self.rom_name.extend([0] * (21 - len(self.rom_name)))
|
||||||
|
|
||||||
self.dragon_rando_type = self.options.dragon_rando_type.value
|
self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
|
||||||
self.dragon_slay_check = self.options.dragon_slay_check.value
|
self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
|
||||||
self.connector_multi_slot = self.options.connector_multi_slot.value
|
self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
|
||||||
self.yorgle_speed = self.options.yorgle_speed.value
|
self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
|
||||||
self.yorgle_min_speed = self.options.yorgle_min_speed.value
|
self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
|
||||||
self.grundle_speed = self.options.grundle_speed.value
|
self.grundle_speed = self.multiworld.grundle_speed[self.player].value
|
||||||
self.grundle_min_speed = self.options.grundle_min_speed.value
|
self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
|
||||||
self.rhindle_speed = self.options.rhindle_speed.value
|
self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
|
||||||
self.rhindle_min_speed = self.options.rhindle_min_speed.value
|
self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
|
||||||
self.difficulty_switch_a = self.options.difficulty_switch_a.value
|
self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
|
||||||
self.difficulty_switch_b = self.options.difficulty_switch_b.value
|
self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
|
||||||
self.start_castle = self.options.start_castle.value
|
self.start_castle = self.multiworld.start_castle[self.player].value
|
||||||
self.created_items = 0
|
self.created_items = 0
|
||||||
|
|
||||||
if self.dragon_slay_check == 0:
|
if self.dragon_slay_check == 0:
|
||||||
@@ -228,7 +228,7 @@ class AdventureWorld(World):
|
|||||||
extra_filler_count = num_locations - self.created_items
|
extra_filler_count = num_locations - self.created_items
|
||||||
|
|
||||||
# traps would probably go here, if enabled
|
# traps would probably go here, if enabled
|
||||||
freeincarnate_max = self.options.freeincarnate_max.value
|
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
|
||||||
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
|
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
|
||||||
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
|
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
|
||||||
self.created_items += actual_freeincarnates
|
self.created_items += actual_freeincarnates
|
||||||
@@ -248,7 +248,7 @@ class AdventureWorld(World):
|
|||||||
self.created_items += 1
|
self.created_items += 1
|
||||||
|
|
||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
|
create_regions(self.multiworld, self.player, self.dragon_rooms)
|
||||||
|
|
||||||
set_rules = set_rules
|
set_rules = set_rules
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ class AdventureWorld(World):
|
|||||||
auto_collect_locations: [AdventureAutoCollectLocation] = []
|
auto_collect_locations: [AdventureAutoCollectLocation] = []
|
||||||
local_item_to_location: {int, int} = {}
|
local_item_to_location: {int, int} = {}
|
||||||
bat_no_touch_locs: [LocationData] = []
|
bat_no_touch_locs: [LocationData] = []
|
||||||
bat_logic: int = self.options.bat_logic.value
|
bat_logic: int = self.multiworld.bat_logic[self.player].value
|
||||||
try:
|
try:
|
||||||
rom_deltas: { int, int } = {}
|
rom_deltas: { int, int } = {}
|
||||||
self.place_dragons(rom_deltas)
|
self.place_dragons(rom_deltas)
|
||||||
@@ -422,7 +422,7 @@ class AdventureWorld(World):
|
|||||||
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
|
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
|
||||||
rom_deltas[item_position_data_start] = 0xff
|
rom_deltas[item_position_data_start] = 0xff
|
||||||
|
|
||||||
if self.options.connector_multi_slot.value:
|
if self.multiworld.connector_multi_slot[self.player].value:
|
||||||
rom_deltas[connector_port_offset] = (self.player & 0xff)
|
rom_deltas[connector_port_offset] = (self.player & 0xff)
|
||||||
else:
|
else:
|
||||||
rom_deltas[connector_port_offset] = 0
|
rom_deltas[connector_port_offset] = 0
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ dw_requirements = {
|
|||||||
|
|
||||||
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
|
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
|
||||||
|
|
||||||
"Rift Collapse: Deep Sea": LocData(hookshot=True),
|
"Rift Collapse - Deep Sea": LocData(hookshot=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Includes main objective requirements
|
# Includes main objective requirements
|
||||||
@@ -55,7 +55,7 @@ dw_bonus_requirements = {
|
|||||||
|
|
||||||
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
|
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
|
||||||
|
|
||||||
"Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]),
|
"Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]),
|
||||||
}
|
}
|
||||||
|
|
||||||
dw_stamp_costs = {
|
dw_stamp_costs = {
|
||||||
@@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"):
|
|||||||
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
|
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
|
||||||
bonus: bool = "All Clear" in loc.name
|
bonus: bool = "All Clear" in loc.name
|
||||||
if not bonus:
|
if not bonus:
|
||||||
data = dw_requirements.get(loc.parent_region.name)
|
data = dw_requirements.get(loc.name)
|
||||||
else:
|
else:
|
||||||
data = dw_bonus_requirements.get(loc.parent_region.name)
|
data = dw_bonus_requirements.get(loc.name)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
|
|||||||
def new_check(location_id):
|
def new_check(location_id):
|
||||||
new_locations.append(location_id)
|
new_locations.append(location_id)
|
||||||
ctx.locations_checked.add(location_id)
|
ctx.locations_checked.add(location_id)
|
||||||
location = ctx.location_names.lookup_in_game(location_id)
|
location = ctx.location_names[location_id]
|
||||||
snes_logger.info(
|
snes_logger.info(
|
||||||
f'New Check: {location} ' +
|
f'New Check: {location} ' +
|
||||||
f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' +
|
f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' +
|
||||||
@@ -552,9 +552,9 @@ class ALTTPSNIClient(SNIClient):
|
|||||||
item = ctx.items_received[recv_index]
|
item = ctx.items_received[recv_index]
|
||||||
recv_index += 1
|
recv_index += 1
|
||||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||||
color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
|
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||||
color(ctx.player_names[item.player], 'yellow'),
|
color(ctx.player_names[item.player], 'yellow'),
|
||||||
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
|
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
|
||||||
|
|
||||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||||
@@ -682,7 +682,7 @@ def get_alttp_settings(romfile: str):
|
|||||||
|
|
||||||
if 'yes' in choice:
|
if 'yes' in choice:
|
||||||
import LttPAdjuster
|
import LttPAdjuster
|
||||||
from .Rom import get_base_rom_path
|
from worlds.alttp.Rom import get_base_rom_path
|
||||||
last_settings.rom = romfile
|
last_settings.rom = romfile
|
||||||
last_settings.baserom = get_base_rom_path()
|
last_settings.baserom = get_base_rom_path()
|
||||||
last_settings.world = None
|
last_settings.world = None
|
||||||
|
|||||||
@@ -1437,7 +1437,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
|
|||||||
invalid_cave_connections = defaultdict(set)
|
invalid_cave_connections = defaultdict(set)
|
||||||
|
|
||||||
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
||||||
from . import OverworldGlitchRules
|
from worlds.alttp import OverworldGlitchRules
|
||||||
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'):
|
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'):
|
||||||
invalid_connections[entrance] = set()
|
invalid_connections[entrance] = set()
|
||||||
if entrance in must_be_exits:
|
if entrance in must_be_exits:
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from BaseClasses import MultiWorld
|
from BaseClasses import MultiWorld
|
||||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, \
|
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool, PlandoBosses,\
|
||||||
StartInventoryPool, PlandoBosses, PlandoConnections, PlandoTexts, FreeText, Removed
|
FreeText, Removed
|
||||||
from .EntranceShuffle import default_connections, default_dungeon_connections, \
|
|
||||||
inverted_default_connections, inverted_default_dungeon_connections
|
|
||||||
from .Text import TextTable
|
|
||||||
|
|
||||||
|
|
||||||
class GlitchesRequired(Choice):
|
class GlitchesRequired(Choice):
|
||||||
@@ -486,7 +483,7 @@ class LTTPBosses(PlandoBosses):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
from .Bosses import can_place_boss
|
from worlds.alttp.Bosses import can_place_boss
|
||||||
level = ''
|
level = ''
|
||||||
words = location.split(" ")
|
words = location.split(" ")
|
||||||
if words[-1] in ("top", "middle", "bottom"):
|
if words[-1] in ("top", "middle", "bottom"):
|
||||||
@@ -724,27 +721,7 @@ class AllowCollect(DefaultOnToggle):
|
|||||||
display_name = "Allow Collection of checks for other players"
|
display_name = "Allow Collection of checks for other players"
|
||||||
|
|
||||||
|
|
||||||
class ALttPPlandoConnections(PlandoConnections):
|
|
||||||
entrances = set([connection[0] for connection in (
|
|
||||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
|
||||||
*inverted_default_dungeon_connections)])
|
|
||||||
exits = set([connection[1] for connection in (
|
|
||||||
*default_connections, *default_dungeon_connections, *inverted_default_connections,
|
|
||||||
*inverted_default_dungeon_connections)])
|
|
||||||
|
|
||||||
|
|
||||||
class ALttPPlandoTexts(PlandoTexts):
|
|
||||||
"""Text plando. Format is:
|
|
||||||
- text: 'This is your text'
|
|
||||||
at: text_key
|
|
||||||
percentage: 100
|
|
||||||
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
|
|
||||||
valid_keys = TextTable.valid_keys
|
|
||||||
|
|
||||||
|
|
||||||
alttp_options: typing.Dict[str, type(Option)] = {
|
alttp_options: typing.Dict[str, type(Option)] = {
|
||||||
"plando_connections": ALttPPlandoConnections,
|
|
||||||
"plando_texts": ALttPPlandoTexts,
|
|
||||||
"start_inventory_from_pool": StartInventoryPool,
|
"start_inventory_from_pool": StartInventoryPool,
|
||||||
"goal": Goal,
|
"goal": Goal,
|
||||||
"mode": Mode,
|
"mode": Mode,
|
||||||
|
|||||||
@@ -220,7 +220,26 @@ def get_invalid_bunny_revival_dungeons():
|
|||||||
yield 'Sanctuary'
|
yield 'Sanctuary'
|
||||||
|
|
||||||
|
|
||||||
|
def no_logic_rules(world, player):
|
||||||
|
"""
|
||||||
|
Add OWG transitions to no logic player's world
|
||||||
|
"""
|
||||||
|
create_no_logic_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
|
||||||
|
create_no_logic_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))
|
||||||
|
|
||||||
|
# Glitched speed drops.
|
||||||
|
create_no_logic_connections(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'))
|
||||||
|
|
||||||
|
# Mirror clip spots.
|
||||||
|
if world.mode[player] != 'inverted':
|
||||||
|
create_no_logic_connections(player, world, get_mirror_clip_spots_dw())
|
||||||
|
create_no_logic_connections(player, world, get_mirror_offset_spots_dw())
|
||||||
|
else:
|
||||||
|
create_no_logic_connections(player, world, get_mirror_offset_spots_lw(player))
|
||||||
|
|
||||||
|
|
||||||
def overworld_glitch_connections(world, player):
|
def overworld_glitch_connections(world, player):
|
||||||
|
|
||||||
# Boots-accessible locations.
|
# Boots-accessible locations.
|
||||||
create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
|
create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
|
||||||
create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))
|
create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ def create_dungeon_region(world: MultiWorld, player: int, name: str, hint: str,
|
|||||||
|
|
||||||
def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionType, hint: str, locations=None,
|
def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionType, hint: str, locations=None,
|
||||||
exits=None):
|
exits=None):
|
||||||
from .SubClasses import ALttPLocation
|
from worlds.alttp.SubClasses import ALttPLocation
|
||||||
ret = LTTPRegion(name, type, hint, player, world)
|
ret = LTTPRegion(name, type, hint, player, world)
|
||||||
if exits:
|
if exits:
|
||||||
for exit in exits:
|
for exit in exits:
|
||||||
@@ -760,7 +760,7 @@ location_table: typing.Dict[str,
|
|||||||
'Turtle Rock - Prize': (
|
'Turtle Rock - Prize': (
|
||||||
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')}
|
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')}
|
||||||
|
|
||||||
from .Shops import shop_table_by_location_id, shop_table_by_location
|
from worlds.alttp.Shops import shop_table_by_location_id, shop_table_by_location
|
||||||
lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int}
|
lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int}
|
||||||
lookup_id_to_name = {**lookup_id_to_name, **{data[1]: name for name, data in key_drop_data.items()}}
|
lookup_id_to_name = {**lookup_id_to_name, **{data[1]: name for name, data in key_drop_data.items()}}
|
||||||
lookup_id_to_name.update(shop_table_by_location_id)
|
lookup_id_to_name.update(shop_table_by_location_id)
|
||||||
|
|||||||
@@ -1269,8 +1269,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
rom.write_int32(0x18020C, 0) # starting time (in frames, sint32)
|
rom.write_int32(0x18020C, 0) # starting time (in frames, sint32)
|
||||||
|
|
||||||
# set up goals for treasure hunt
|
# set up goals for treasure hunt
|
||||||
rom.write_int16(0x180163, max(0, local_world.treasure_hunt_required -
|
rom.write_int16(0x180163, local_world.treasure_hunt_required)
|
||||||
sum(1 for item in world.precollected_items[player] if item.name == "Triforce Piece")))
|
|
||||||
rom.write_bytes(0x180165, [0x0E, 0x28]) # Triforce Piece Sprite
|
rom.write_bytes(0x180165, [0x0E, 0x28]) # Triforce Piece Sprite
|
||||||
rom.write_byte(0x180194, 1) # Must turn in triforced pieces (instant win not enabled)
|
rom.write_byte(0x180194, 1) # Must turn in triforced pieces (instant win not enabled)
|
||||||
|
|
||||||
@@ -1373,7 +1372,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword',
|
'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword',
|
||||||
'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield',
|
'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield',
|
||||||
'Red Mail', 'Blue Mail', 'Progressive Mail',
|
'Red Mail', 'Blue Mail', 'Progressive Mail',
|
||||||
'Magic Upgrade (1/4)', 'Magic Upgrade (1/2)', 'Triforce Piece'}:
|
'Magic Upgrade (1/4)', 'Magic Upgrade (1/2)'}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
set_table = {'Book of Mudora': (0x34E, 1), 'Hammer': (0x34B, 1), 'Bug Catching Net': (0x34D, 1),
|
set_table = {'Book of Mudora': (0x34E, 1), 'Hammer': (0x34B, 1), 'Bug Catching Net': (0x34D, 1),
|
||||||
@@ -2476,9 +2475,6 @@ def write_strings(rom, world, player):
|
|||||||
tt['sahasrahla_quest_have_master_sword'] = Sahasrahla2_texts[local_random.randint(0, len(Sahasrahla2_texts) - 1)]
|
tt['sahasrahla_quest_have_master_sword'] = Sahasrahla2_texts[local_random.randint(0, len(Sahasrahla2_texts) - 1)]
|
||||||
tt['blind_by_the_light'] = Blind_texts[local_random.randint(0, len(Blind_texts) - 1)]
|
tt['blind_by_the_light'] = Blind_texts[local_random.randint(0, len(Blind_texts) - 1)]
|
||||||
|
|
||||||
triforce_pieces_required = max(0, w.treasure_hunt_required -
|
|
||||||
sum(1 for item in world.precollected_items[player] if item.name == "Triforce Piece"))
|
|
||||||
|
|
||||||
if world.goal[player] in ['triforce_hunt', 'local_triforce_hunt']:
|
if world.goal[player] in ['triforce_hunt', 'local_triforce_hunt']:
|
||||||
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Get the Triforce Pieces.'
|
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Get the Triforce Pieces.'
|
||||||
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
|
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
|
||||||
@@ -2486,16 +2482,16 @@ def write_strings(rom, world, player):
|
|||||||
tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!'
|
tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!'
|
||||||
else:
|
else:
|
||||||
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
|
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
|
||||||
if triforce_pieces_required > 1:
|
if w.treasure_hunt_required > 1:
|
||||||
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
||||||
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
||||||
"hidden in a hollow tree. If you bring\n%d Triforce pieces out of %d, I can reassemble it." % \
|
"hidden in a hollow tree. If you bring\n%d Triforce pieces out of %d, I can reassemble it." % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
else:
|
else:
|
||||||
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
||||||
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
||||||
"hidden in a hollow tree. If you bring\n%d Triforce piece out of %d, I can reassemble it." % \
|
"hidden in a hollow tree. If you bring\n%d Triforce piece out of %d, I can reassemble it." % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
elif world.goal[player] in ['pedestal']:
|
elif world.goal[player] in ['pedestal']:
|
||||||
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.'
|
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.'
|
||||||
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
|
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
|
||||||
@@ -2504,20 +2500,20 @@ def write_strings(rom, world, player):
|
|||||||
tt['ganon_fall_in'] = Ganon1_texts[local_random.randint(0, len(Ganon1_texts) - 1)]
|
tt['ganon_fall_in'] = Ganon1_texts[local_random.randint(0, len(Ganon1_texts) - 1)]
|
||||||
tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!'
|
tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!'
|
||||||
tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!'
|
tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!'
|
||||||
if triforce_pieces_required > 1:
|
if w.treasure_hunt_required > 1:
|
||||||
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
||||||
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d with your friends to defeat Ganon.' % \
|
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d with your friends to defeat Ganon.' % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
||||||
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d to defeat Ganon.' % \
|
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d to defeat Ganon.' % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
else:
|
else:
|
||||||
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
||||||
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d with your friends to defeat Ganon.' % \
|
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d with your friends to defeat Ganon.' % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
||||||
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d to defeat Ganon.' % \
|
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d to defeat Ganon.' % \
|
||||||
(triforce_pieces_required, w.treasure_hunt_total)
|
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||||
|
|
||||||
tt['kakariko_tavern_fisherman'] = TavernMan_texts[local_random.randint(0, len(TavernMan_texts) - 1)]
|
tt['kakariko_tavern_fisherman'] = TavernMan_texts[local_random.randint(0, len(TavernMan_texts) - 1)]
|
||||||
|
|
||||||
@@ -2542,12 +2538,12 @@ def write_strings(rom, world, player):
|
|||||||
tt['menu_start_2'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n{CHOICE3}"
|
tt['menu_start_2'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n{CHOICE3}"
|
||||||
tt['menu_start_3'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n Mountain Cave\n{CHOICE2}"
|
tt['menu_start_3'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n Mountain Cave\n{CHOICE2}"
|
||||||
|
|
||||||
for at, text, _ in world.plando_texts[player]:
|
for at, text in world.plando_texts[player].items():
|
||||||
|
|
||||||
if at not in tt:
|
if at not in tt:
|
||||||
raise Exception(f"No text target \"{at}\" found.")
|
raise Exception(f"No text target \"{at}\" found.")
|
||||||
else:
|
else:
|
||||||
tt[at] = "\n".join(text)
|
tt[at] = text
|
||||||
|
|
||||||
rom.write_bytes(0xE0000, tt.getBytes())
|
rom.write_bytes(0xE0000, tt.getBytes())
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from . import OverworldGlitchRules
|
|||||||
from .Bosses import GanonDefeatRule
|
from .Bosses import GanonDefeatRule
|
||||||
from .Items import item_factory, item_name_groups, item_table, progression_items
|
from .Items import item_factory, item_name_groups, item_table, progression_items
|
||||||
from .Options import small_key_shuffle
|
from .Options import small_key_shuffle
|
||||||
from .OverworldGlitchRules import overworld_glitches_rules
|
from .OverworldGlitchRules import no_logic_rules, overworld_glitches_rules
|
||||||
from .Regions import LTTPRegionType, location_table
|
from .Regions import LTTPRegionType, location_table
|
||||||
from .StateHelpers import (can_extend_magic, can_kill_most_things,
|
from .StateHelpers import (can_extend_magic, can_kill_most_things,
|
||||||
can_lift_heavy_rocks, can_lift_rocks,
|
can_lift_heavy_rocks, can_lift_rocks,
|
||||||
@@ -33,6 +33,7 @@ def set_rules(world):
|
|||||||
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
|
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
|
||||||
|
|
||||||
if world.players == 1:
|
if world.players == 1:
|
||||||
|
no_logic_rules(world, player)
|
||||||
for exit in world.get_region('Menu', player).exits:
|
for exit in world.get_region('Menu', player).exits:
|
||||||
exit.hide_path = True
|
exit.hide_path = True
|
||||||
return
|
return
|
||||||
@@ -405,14 +406,16 @@ def global_rules(multiworld: MultiWorld, player: int):
|
|||||||
set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))
|
set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))
|
||||||
|
|
||||||
set_rule(multiworld.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player))
|
set_rule(multiworld.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player))
|
||||||
|
|
||||||
if multiworld.worlds[player].dungeons["Thieves Town"].boss.enemizer_name == "Blind":
|
if multiworld.worlds[player].dungeons["Thieves Town"].boss.enemizer_name == "Blind":
|
||||||
set_rule(multiworld.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3) and can_use_bombs(state, player))
|
set_rule(multiworld.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3) and can_use_bombs(state, player))
|
||||||
|
|
||||||
set_rule(multiworld.get_location('Thieves\' Town - Big Chest', player),
|
set_rule(multiworld.get_location('Thieves\' Town - Big Chest', player),
|
||||||
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
|
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
|
||||||
set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
|
|
||||||
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
|
||||||
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
|
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
|
||||||
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
|
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
|
||||||
|
|
||||||
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
|
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
|
||||||
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
|
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
|
||||||
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from worlds.generic.Rules import add_rule
|
|||||||
|
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState
|
||||||
from .SubClasses import ALttPLocation
|
from .SubClasses import ALttPLocation
|
||||||
|
from .EntranceShuffle import door_addresses
|
||||||
from .Items import item_name_groups
|
from .Items import item_name_groups
|
||||||
|
from .Options import small_key_shuffle, RandomizeShopInventories
|
||||||
from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows
|
from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows
|
||||||
|
|
||||||
logger = logging.getLogger("Shops")
|
logger = logging.getLogger("Shops")
|
||||||
@@ -66,7 +66,6 @@ class Shop:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_bytes(self) -> List[int]:
|
def get_bytes(self) -> List[int]:
|
||||||
from .EntranceShuffle import door_addresses
|
|
||||||
# [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index]
|
# [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index]
|
||||||
entrances = self.region.entrances
|
entrances = self.region.entrances
|
||||||
config = self.item_count
|
config = self.item_count
|
||||||
@@ -182,7 +181,7 @@ def push_shop_inventories(multiworld):
|
|||||||
|
|
||||||
|
|
||||||
def create_shops(multiworld, player: int):
|
def create_shops(multiworld, player: int):
|
||||||
from .Options import RandomizeShopInventories
|
|
||||||
player_shop_table = shop_table.copy()
|
player_shop_table = shop_table.copy()
|
||||||
if multiworld.include_witch_hut[player]:
|
if multiworld.include_witch_hut[player]:
|
||||||
player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False)
|
player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False)
|
||||||
@@ -305,7 +304,6 @@ shop_generation_types = {
|
|||||||
|
|
||||||
|
|
||||||
def set_up_shops(multiworld, player: int):
|
def set_up_shops(multiworld, player: int):
|
||||||
from .Options import small_key_shuffle
|
|
||||||
# TODO: move hard+ mode changes for shields here, utilizing the new shops
|
# TODO: move hard+ mode changes for shields here, utilizing the new shops
|
||||||
|
|
||||||
if multiworld.retro_bow[player]:
|
if multiworld.retro_bow[player]:
|
||||||
@@ -428,7 +426,7 @@ def get_price_modifier(item):
|
|||||||
|
|
||||||
def get_price(multiworld, item, player: int, price_type=None):
|
def get_price(multiworld, item, player: int, price_type=None):
|
||||||
"""Converts a raw Rupee price into a special price type"""
|
"""Converts a raw Rupee price into a special price type"""
|
||||||
from .Options import small_key_shuffle
|
|
||||||
if price_type:
|
if price_type:
|
||||||
price_types = [price_type]
|
price_types = [price_type]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1289,415 +1289,6 @@ class LargeCreditBottomMapper(CharTextMapper):
|
|||||||
class TextTable(object):
|
class TextTable(object):
|
||||||
SIZE = 0x7355
|
SIZE = 0x7355
|
||||||
|
|
||||||
valid_keys = [
|
|
||||||
"set_cursor",
|
|
||||||
"set_cursor2",
|
|
||||||
"game_over_menu",
|
|
||||||
"var_test",
|
|
||||||
"follower_no_enter",
|
|
||||||
"choice_1_3",
|
|
||||||
"choice_2_3",
|
|
||||||
"choice_3_3",
|
|
||||||
"choice_1_2",
|
|
||||||
"choice_2_2",
|
|
||||||
"uncle_leaving_text",
|
|
||||||
"uncle_dying_sewer",
|
|
||||||
"tutorial_guard_1",
|
|
||||||
"tutorial_guard_2",
|
|
||||||
"tutorial_guard_3",
|
|
||||||
"tutorial_guard_4",
|
|
||||||
"tutorial_guard_5",
|
|
||||||
"tutorial_guard_6",
|
|
||||||
"tutorial_guard_7",
|
|
||||||
"priest_sanctuary_before_leave",
|
|
||||||
"sanctuary_enter",
|
|
||||||
"zelda_sanctuary_story",
|
|
||||||
"priest_sanctuary_before_pendants",
|
|
||||||
"priest_sanctuary_after_pendants_before_master_sword",
|
|
||||||
"priest_sanctuary_dying",
|
|
||||||
"zelda_save_sewers",
|
|
||||||
"priest_info",
|
|
||||||
"zelda_sanctuary_before_leave",
|
|
||||||
"telepathic_intro",
|
|
||||||
"telepathic_reminder",
|
|
||||||
"zelda_go_to_throne",
|
|
||||||
"zelda_push_throne",
|
|
||||||
"zelda_switch_room_pull",
|
|
||||||
"zelda_save_lets_go",
|
|
||||||
"zelda_save_repeat",
|
|
||||||
"zelda_before_pendants",
|
|
||||||
"zelda_after_pendants_before_master_sword",
|
|
||||||
"telepathic_zelda_right_after_master_sword",
|
|
||||||
"zelda_sewers",
|
|
||||||
"zelda_switch_room",
|
|
||||||
"kakariko_saharalasa_wife",
|
|
||||||
"kakariko_saharalasa_wife_sword_story",
|
|
||||||
"kakariko_saharalasa_wife_closing",
|
|
||||||
"kakariko_saharalasa_after_master_sword",
|
|
||||||
"kakariko_alert_guards",
|
|
||||||
"sahasrahla_quest_have_pendants",
|
|
||||||
"sahasrahla_quest_have_master_sword",
|
|
||||||
"sahasrahla_quest_information",
|
|
||||||
"sahasrahla_bring_courage",
|
|
||||||
"sahasrahla_have_ice_rod",
|
|
||||||
"telepathic_sahasrahla_beat_agahnim",
|
|
||||||
"telepathic_sahasrahla_beat_agahnim_no_pearl",
|
|
||||||
"sahasrahla_have_boots_no_icerod",
|
|
||||||
"sahasrahla_have_courage",
|
|
||||||
"sahasrahla_found",
|
|
||||||
"sign_rain_north_of_links_house",
|
|
||||||
"sign_north_of_links_house",
|
|
||||||
"sign_path_to_death_mountain",
|
|
||||||
"sign_lost_woods",
|
|
||||||
"sign_zoras",
|
|
||||||
"sign_outside_magic_shop",
|
|
||||||
"sign_death_mountain_cave_back",
|
|
||||||
"sign_east_of_links_house",
|
|
||||||
"sign_south_of_lumberjacks",
|
|
||||||
"sign_east_of_desert",
|
|
||||||
"sign_east_of_sanctuary",
|
|
||||||
"sign_east_of_castle",
|
|
||||||
"sign_north_of_lake",
|
|
||||||
"sign_desert_thief",
|
|
||||||
"sign_lumberjacks_house",
|
|
||||||
"sign_north_kakariko",
|
|
||||||
"witch_bring_mushroom",
|
|
||||||
"witch_brewing_the_item",
|
|
||||||
"witch_assistant_no_bottle",
|
|
||||||
"witch_assistant_no_empty_bottle",
|
|
||||||
"witch_assistant_informational",
|
|
||||||
"witch_assistant_no_bottle_buying",
|
|
||||||
"potion_shop_no_empty_bottles",
|
|
||||||
"item_get_lamp",
|
|
||||||
"item_get_boomerang",
|
|
||||||
"item_get_bow",
|
|
||||||
"item_get_shovel",
|
|
||||||
"item_get_magic_cape",
|
|
||||||
"item_get_powder",
|
|
||||||
"item_get_flippers",
|
|
||||||
"item_get_power_gloves",
|
|
||||||
"item_get_pendant_courage",
|
|
||||||
"item_get_pendant_power",
|
|
||||||
"item_get_pendant_wisdom",
|
|
||||||
"item_get_mushroom",
|
|
||||||
"item_get_book",
|
|
||||||
"item_get_moonpearl",
|
|
||||||
"item_get_compass",
|
|
||||||
"item_get_map",
|
|
||||||
"item_get_ice_rod",
|
|
||||||
"item_get_fire_rod",
|
|
||||||
"item_get_ether",
|
|
||||||
"item_get_bombos",
|
|
||||||
"item_get_quake",
|
|
||||||
"item_get_hammer",
|
|
||||||
"item_get_flute",
|
|
||||||
"item_get_cane_of_somaria",
|
|
||||||
"item_get_hookshot",
|
|
||||||
"item_get_bombs",
|
|
||||||
"item_get_bottle",
|
|
||||||
"item_get_big_key",
|
|
||||||
"item_get_titans_mitts",
|
|
||||||
"item_get_magic_mirror",
|
|
||||||
"item_get_fake_mastersword",
|
|
||||||
"post_item_get_mastersword",
|
|
||||||
"item_get_red_potion",
|
|
||||||
"item_get_green_potion",
|
|
||||||
"item_get_blue_potion",
|
|
||||||
"item_get_bug_net",
|
|
||||||
"item_get_blue_mail",
|
|
||||||
"item_get_red_mail",
|
|
||||||
"item_get_temperedsword",
|
|
||||||
"item_get_mirror_shield",
|
|
||||||
"item_get_cane_of_byrna",
|
|
||||||
"missing_big_key",
|
|
||||||
"missing_magic",
|
|
||||||
"item_get_pegasus_boots",
|
|
||||||
"talking_tree_info_start",
|
|
||||||
"talking_tree_info_1",
|
|
||||||
"talking_tree_info_2",
|
|
||||||
"talking_tree_info_3",
|
|
||||||
"talking_tree_info_4",
|
|
||||||
"talking_tree_other",
|
|
||||||
"item_get_pendant_power_alt",
|
|
||||||
"item_get_pendant_wisdom_alt",
|
|
||||||
"game_shooting_choice",
|
|
||||||
"game_shooting_yes",
|
|
||||||
"game_shooting_no",
|
|
||||||
"game_shooting_continue",
|
|
||||||
"pond_of_wishing",
|
|
||||||
"pond_item_select",
|
|
||||||
"pond_item_test",
|
|
||||||
"pond_will_upgrade",
|
|
||||||
"pond_item_test_no",
|
|
||||||
"pond_item_test_no_no",
|
|
||||||
"pond_item_boomerang",
|
|
||||||
"pond_item_shield",
|
|
||||||
"pond_item_silvers",
|
|
||||||
"pond_item_bottle_filled",
|
|
||||||
"pond_item_sword",
|
|
||||||
"pond_of_wishing_happiness",
|
|
||||||
"pond_of_wishing_choice",
|
|
||||||
"pond_of_wishing_bombs",
|
|
||||||
"pond_of_wishing_arrows",
|
|
||||||
"pond_of_wishing_full_upgrades",
|
|
||||||
"mountain_old_man_first",
|
|
||||||
"mountain_old_man_deadend",
|
|
||||||
"mountain_old_man_turn_right",
|
|
||||||
"mountain_old_man_lost_and_alone",
|
|
||||||
"mountain_old_man_drop_off",
|
|
||||||
"mountain_old_man_in_his_cave_pre_agahnim",
|
|
||||||
"mountain_old_man_in_his_cave",
|
|
||||||
"mountain_old_man_in_his_cave_post_agahnim",
|
|
||||||
"tavern_old_man_awake",
|
|
||||||
"tavern_old_man_unactivated_flute",
|
|
||||||
"tavern_old_man_know_tree_unactivated_flute",
|
|
||||||
"tavern_old_man_have_flute",
|
|
||||||
"chicken_hut_lady",
|
|
||||||
"running_man",
|
|
||||||
"game_race_sign",
|
|
||||||
"sign_bumper_cave",
|
|
||||||
"sign_catfish",
|
|
||||||
"sign_north_village_of_outcasts",
|
|
||||||
"sign_south_of_bumper_cave",
|
|
||||||
"sign_east_of_pyramid",
|
|
||||||
"sign_east_of_bomb_shop",
|
|
||||||
"sign_east_of_mire",
|
|
||||||
"sign_village_of_outcasts",
|
|
||||||
"sign_before_wishing_pond",
|
|
||||||
"sign_before_catfish_area",
|
|
||||||
"castle_wall_guard",
|
|
||||||
"gate_guard",
|
|
||||||
"telepathic_tile_eastern_palace",
|
|
||||||
"telepathic_tile_tower_of_hera_floor_4",
|
|
||||||
"hylian_text_1",
|
|
||||||
"mastersword_pedestal_translated",
|
|
||||||
"telepathic_tile_spectacle_rock",
|
|
||||||
"telepathic_tile_swamp_entrance",
|
|
||||||
"telepathic_tile_thieves_town_upstairs",
|
|
||||||
"telepathic_tile_misery_mire",
|
|
||||||
"hylian_text_2",
|
|
||||||
"desert_entry_translated",
|
|
||||||
"telepathic_tile_under_ganon",
|
|
||||||
"telepathic_tile_palace_of_darkness",
|
|
||||||
"telepathic_tile_desert_bonk_torch_room",
|
|
||||||
"telepathic_tile_castle_tower",
|
|
||||||
"telepathic_tile_ice_large_room",
|
|
||||||
"telepathic_tile_turtle_rock",
|
|
||||||
"telepathic_tile_ice_entrance",
|
|
||||||
"telepathic_tile_ice_stalfos_knights_room",
|
|
||||||
"telepathic_tile_tower_of_hera_entrance",
|
|
||||||
"houlihan_room",
|
|
||||||
"caught_a_bee",
|
|
||||||
"caught_a_fairy",
|
|
||||||
"no_empty_bottles",
|
|
||||||
"game_race_boy_time",
|
|
||||||
"game_race_girl",
|
|
||||||
"game_race_boy_success",
|
|
||||||
"game_race_boy_failure",
|
|
||||||
"game_race_boy_already_won",
|
|
||||||
"game_race_boy_sneaky",
|
|
||||||
"bottle_vendor_choice",
|
|
||||||
"bottle_vendor_get",
|
|
||||||
"bottle_vendor_no",
|
|
||||||
"bottle_vendor_already_collected",
|
|
||||||
"bottle_vendor_bee",
|
|
||||||
"bottle_vendor_fish",
|
|
||||||
"hobo_item_get_bottle",
|
|
||||||
"blacksmiths_what_you_want",
|
|
||||||
"blacksmiths_paywall",
|
|
||||||
"blacksmiths_extra_okay",
|
|
||||||
"blacksmiths_tempered_already",
|
|
||||||
"blacksmiths_temper_no",
|
|
||||||
"blacksmiths_bogart_sword",
|
|
||||||
"blacksmiths_get_sword",
|
|
||||||
"blacksmiths_shop_before_saving",
|
|
||||||
"blacksmiths_shop_saving",
|
|
||||||
"blacksmiths_collect_frog",
|
|
||||||
"blacksmiths_still_working",
|
|
||||||
"blacksmiths_saving_bows",
|
|
||||||
"blacksmiths_hammer_anvil",
|
|
||||||
"dark_flute_boy_storytime",
|
|
||||||
"dark_flute_boy_get_shovel",
|
|
||||||
"dark_flute_boy_no_get_shovel",
|
|
||||||
"dark_flute_boy_flute_not_found",
|
|
||||||
"dark_flute_boy_after_shovel_get",
|
|
||||||
"shop_fortune_teller_lw_hint_0",
|
|
||||||
"shop_fortune_teller_lw_hint_1",
|
|
||||||
"shop_fortune_teller_lw_hint_2",
|
|
||||||
"shop_fortune_teller_lw_hint_3",
|
|
||||||
"shop_fortune_teller_lw_hint_4",
|
|
||||||
"shop_fortune_teller_lw_hint_5",
|
|
||||||
"shop_fortune_teller_lw_hint_6",
|
|
||||||
"shop_fortune_teller_lw_hint_7",
|
|
||||||
"shop_fortune_teller_lw_no_rupees",
|
|
||||||
"shop_fortune_teller_lw",
|
|
||||||
"shop_fortune_teller_lw_post_hint",
|
|
||||||
"shop_fortune_teller_lw_no",
|
|
||||||
"shop_fortune_teller_lw_hint_8",
|
|
||||||
"shop_fortune_teller_lw_hint_9",
|
|
||||||
"shop_fortune_teller_lw_hint_10",
|
|
||||||
"shop_fortune_teller_lw_hint_11",
|
|
||||||
"shop_fortune_teller_lw_hint_12",
|
|
||||||
"shop_fortune_teller_lw_hint_13",
|
|
||||||
"shop_fortune_teller_lw_hint_14",
|
|
||||||
"shop_fortune_teller_lw_hint_15",
|
|
||||||
"dark_sanctuary",
|
|
||||||
"dark_sanctuary_hint_0",
|
|
||||||
"dark_sanctuary_no",
|
|
||||||
"dark_sanctuary_hint_1",
|
|
||||||
"dark_sanctuary_yes",
|
|
||||||
"dark_sanctuary_hint_2",
|
|
||||||
"sick_kid_no_bottle",
|
|
||||||
"sick_kid_trade",
|
|
||||||
"sick_kid_post_trade",
|
|
||||||
"desert_thief_sitting",
|
|
||||||
"desert_thief_following",
|
|
||||||
"desert_thief_question",
|
|
||||||
"desert_thief_question_yes",
|
|
||||||
"desert_thief_after_item_get",
|
|
||||||
"desert_thief_reassure",
|
|
||||||
"hylian_text_3",
|
|
||||||
"tablet_ether_book",
|
|
||||||
"tablet_bombos_book",
|
|
||||||
"magic_bat_wake",
|
|
||||||
"magic_bat_give_half_magic",
|
|
||||||
"intro_main",
|
|
||||||
"intro_throne_room",
|
|
||||||
"intro_zelda_cell",
|
|
||||||
"intro_agahnim",
|
|
||||||
"pickup_purple_chest",
|
|
||||||
"bomb_shop",
|
|
||||||
"bomb_shop_big_bomb",
|
|
||||||
"bomb_shop_big_bomb_buy",
|
|
||||||
"item_get_big_bomb",
|
|
||||||
"kiki_second_extortion",
|
|
||||||
"kiki_second_extortion_no",
|
|
||||||
"kiki_second_extortion_yes",
|
|
||||||
"kiki_first_extortion",
|
|
||||||
"kiki_first_extortion_yes",
|
|
||||||
"kiki_first_extortion_no",
|
|
||||||
"kiki_leaving_screen",
|
|
||||||
"blind_in_the_cell",
|
|
||||||
"blind_by_the_light",
|
|
||||||
"blind_not_that_way",
|
|
||||||
"aginah_l1sword_no_book",
|
|
||||||
"aginah_l1sword_with_pendants",
|
|
||||||
"aginah",
|
|
||||||
"aginah_need_better_sword",
|
|
||||||
"aginah_have_better_sword",
|
|
||||||
"catfish",
|
|
||||||
"catfish_after_item",
|
|
||||||
"lumberjack_right",
|
|
||||||
"lumberjack_left",
|
|
||||||
"lumberjack_left_post_agahnim",
|
|
||||||
"fighting_brothers_right",
|
|
||||||
"fighting_brothers_right_opened",
|
|
||||||
"fighting_brothers_left",
|
|
||||||
"maiden_crystal_1",
|
|
||||||
"maiden_crystal_2",
|
|
||||||
"maiden_crystal_3",
|
|
||||||
"maiden_crystal_4",
|
|
||||||
"maiden_crystal_5",
|
|
||||||
"maiden_crystal_6",
|
|
||||||
"maiden_crystal_7",
|
|
||||||
"maiden_ending",
|
|
||||||
"maiden_confirm_understood",
|
|
||||||
"barrier_breaking",
|
|
||||||
"maiden_crystal_7_again",
|
|
||||||
"agahnim_zelda_teleport",
|
|
||||||
"agahnim_magic_running_away",
|
|
||||||
"agahnim_hide_and_seek_found",
|
|
||||||
"agahnim_defeated",
|
|
||||||
"agahnim_final_meeting",
|
|
||||||
"zora_meeting",
|
|
||||||
"zora_tells_cost",
|
|
||||||
"zora_get_flippers",
|
|
||||||
"zora_no_cash",
|
|
||||||
"zora_no_buy_item",
|
|
||||||
"kakariko_saharalasa_grandson",
|
|
||||||
"kakariko_saharalasa_grandson_next",
|
|
||||||
"dark_palace_tree_dude",
|
|
||||||
"fairy_wishing_ponds",
|
|
||||||
"fairy_wishing_ponds_no",
|
|
||||||
"pond_of_wishing_no",
|
|
||||||
"pond_of_wishing_return_item",
|
|
||||||
"pond_of_wishing_throw",
|
|
||||||
"pond_pre_item_silvers",
|
|
||||||
"pond_of_wishing_great_luck",
|
|
||||||
"pond_of_wishing_good_luck",
|
|
||||||
"pond_of_wishing_meh_luck",
|
|
||||||
"pond_of_wishing_bad_luck",
|
|
||||||
"pond_of_wishing_fortune",
|
|
||||||
"item_get_14_heart",
|
|
||||||
"item_get_24_heart",
|
|
||||||
"item_get_34_heart",
|
|
||||||
"item_get_whole_heart",
|
|
||||||
"item_get_sanc_heart",
|
|
||||||
"fairy_fountain_refill",
|
|
||||||
"death_mountain_bullied_no_pearl",
|
|
||||||
"death_mountain_bullied_with_pearl",
|
|
||||||
"death_mountain_bully_no_pearl",
|
|
||||||
"death_mountain_bully_with_pearl",
|
|
||||||
"shop_darkworld_enter",
|
|
||||||
"game_chest_village_of_outcasts",
|
|
||||||
"game_chest_no_cash",
|
|
||||||
"game_chest_not_played",
|
|
||||||
"game_chest_played",
|
|
||||||
"game_chest_village_of_outcasts_play",
|
|
||||||
"shop_first_time",
|
|
||||||
"shop_already_have",
|
|
||||||
"shop_buy_shield",
|
|
||||||
"shop_buy_red_potion",
|
|
||||||
"shop_buy_arrows",
|
|
||||||
"shop_buy_bombs",
|
|
||||||
"shop_buy_bee",
|
|
||||||
"shop_buy_heart",
|
|
||||||
"shop_first_no_bottle_buy",
|
|
||||||
"shop_buy_no_space",
|
|
||||||
"ganon_fall_in",
|
|
||||||
"ganon_phase_3",
|
|
||||||
"lost_woods_thief",
|
|
||||||
"blinds_hut_dude",
|
|
||||||
"end_triforce",
|
|
||||||
"toppi_fallen",
|
|
||||||
"kakariko_tavern_fisherman",
|
|
||||||
"thief_money",
|
|
||||||
"thief_desert_rupee_cave",
|
|
||||||
"thief_ice_rupee_cave",
|
|
||||||
"telepathic_tile_south_east_darkworld_cave",
|
|
||||||
"cukeman",
|
|
||||||
"cukeman_2",
|
|
||||||
"potion_shop_no_cash",
|
|
||||||
"kakariko_powdered_chicken",
|
|
||||||
"game_chest_south_of_kakariko",
|
|
||||||
"game_chest_play_yes",
|
|
||||||
"game_chest_play_no",
|
|
||||||
"game_chest_lost_woods",
|
|
||||||
"kakariko_flophouse_man_no_flippers",
|
|
||||||
"kakariko_flophouse_man",
|
|
||||||
"menu_start_2",
|
|
||||||
"menu_start_3",
|
|
||||||
"menu_pause",
|
|
||||||
"game_digging_choice",
|
|
||||||
"game_digging_start",
|
|
||||||
"game_digging_no_cash",
|
|
||||||
"game_digging_end_time",
|
|
||||||
"game_digging_come_back_later",
|
|
||||||
"game_digging_no_follower",
|
|
||||||
"menu_start_4",
|
|
||||||
"ganon_fall_in_alt",
|
|
||||||
"ganon_phase_3_alt",
|
|
||||||
"sign_east_death_mountain_bridge",
|
|
||||||
"fish_money",
|
|
||||||
"sign_ganons_tower",
|
|
||||||
"sign_ganon",
|
|
||||||
"ganon_phase_3_no_bow",
|
|
||||||
"ganon_phase_3_no_silvers_alt",
|
|
||||||
"ganon_phase_3_no_silvers",
|
|
||||||
"ganon_phase_3_silvers",
|
|
||||||
"murahdahla",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._text = OrderedDict()
|
self._text = OrderedDict()
|
||||||
self.setDefaultText()
|
self.setDefaultText()
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ class ALTTPWorld(World):
|
|||||||
item_name_to_id = {name: data.item_code for name, data in item_table.items() if type(data.item_code) == int}
|
item_name_to_id = {name: data.item_code for name, data in item_table.items() if type(data.item_code) == int}
|
||||||
location_name_to_id = lookup_name_to_id
|
location_name_to_id = lookup_name_to_id
|
||||||
|
|
||||||
|
data_version = 9
|
||||||
required_client_version = (0, 4, 1)
|
required_client_version = (0, 4, 1)
|
||||||
web = ALTTPWeb()
|
web = ALTTPWeb()
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ class TestThievesTown(TestDungeon):
|
|||||||
|
|
||||||
["Thieves' Town - Blind's Cell", False, []],
|
["Thieves' Town - Blind's Cell", False, []],
|
||||||
["Thieves' Town - Blind's Cell", False, [], ['Big Key (Thieves Town)']],
|
["Thieves' Town - Blind's Cell", False, [], ['Big Key (Thieves Town)']],
|
||||||
["Thieves' Town - Blind's Cell", False, [], ['Small Key (Thieves Town)']],
|
["Thieves' Town - Blind's Cell", True, ['Big Key (Thieves Town)']],
|
||||||
["Thieves' Town - Blind's Cell", True, ['Big Key (Thieves Town)', 'Small Key (Thieves Town)']],
|
|
||||||
|
|
||||||
["Thieves' Town - Boss", False, []],
|
["Thieves' Town - Boss", False, []],
|
||||||
["Thieves' Town - Boss", False, [], ['Big Key (Thieves Town)']],
|
["Thieves' Town - Boss", False, [], ['Big Key (Thieves Town)']],
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
from typing import Dict
|
|
||||||
|
|
||||||
from BaseClasses import Tutorial
|
|
||||||
from ..AutoWorld import WebWorld, World
|
|
||||||
|
|
||||||
class AP_SudokuWebWorld(WebWorld):
|
|
||||||
options_page = "games/Sudoku/info/en"
|
|
||||||
theme = 'partyTime'
|
|
||||||
|
|
||||||
setup_en = Tutorial(
|
|
||||||
tutorial_name='Setup Guide',
|
|
||||||
description='A guide to playing APSudoku',
|
|
||||||
language='English',
|
|
||||||
file_name='setup_en.md',
|
|
||||||
link='setup/en',
|
|
||||||
authors=['EmilyV']
|
|
||||||
)
|
|
||||||
|
|
||||||
tutorials = [setup_en]
|
|
||||||
|
|
||||||
class AP_SudokuWorld(World):
|
|
||||||
"""
|
|
||||||
Play a little Sudoku while you're in BK mode to maybe get some useful hints
|
|
||||||
"""
|
|
||||||
game = "Sudoku"
|
|
||||||
web = AP_SudokuWebWorld()
|
|
||||||
|
|
||||||
item_name_to_id: Dict[str, int] = {}
|
|
||||||
location_name_to_id: Dict[str, int] = {}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def stage_assert_generate(cls, multiworld):
|
|
||||||
raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user