mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 07:23:28 -07:00
Compare commits
107 Commits
NewSoupVi-
...
0.6.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecb22642af | ||
|
|
17ccfdc266 | ||
|
|
4633f12972 | ||
|
|
1f6c99635e | ||
|
|
4e92cac171 | ||
|
|
3b88630b0d | ||
|
|
e6d2d8f455 | ||
|
|
84c2d70d9a | ||
|
|
d408f7cabc | ||
|
|
72ae076ce7 | ||
|
|
277f21db7a | ||
|
|
9edd55961f | ||
|
|
9ad6959559 | ||
|
|
37a9d94865 | ||
|
|
e8f5bc1c96 | ||
|
|
8bb236411d | ||
|
|
332f955159 | ||
|
|
e7131eddc2 | ||
|
|
8c07a2c930 | ||
|
|
2fe51d087f | ||
|
|
b1f729a970 | ||
|
|
754e0a0de4 | ||
|
|
7abe7fe304 | ||
|
|
8a552e3639 | ||
|
|
743501addc | ||
|
|
6125e59ce3 | ||
|
|
1d8a0b2940 | ||
|
|
2a0ed7faa2 | ||
|
|
ad17c7fd21 | ||
|
|
4d17366662 | ||
|
|
5e2702090c | ||
|
|
f8d1e4edf3 | ||
|
|
04a3f78605 | ||
|
|
ea1e074083 | ||
|
|
199a6df65e | ||
|
|
c9ebf69e0d | ||
|
|
a36e6259f1 | ||
|
|
de4014f02c | ||
|
|
774457b362 | ||
|
|
7a8048a8fd | ||
|
|
fa49fef695 | ||
|
|
faac2540bf | ||
|
|
4e1eb78163 | ||
|
|
46829487d6 | ||
|
|
8fd021e757 | ||
|
|
a3af953683 | ||
|
|
f27da5cc78 | ||
|
|
23f0b720de | ||
|
|
f66d8e9a61 | ||
|
|
8499c2fd24 | ||
|
|
ea4c4dcc0c | ||
|
|
88e8e2408b | ||
|
|
e5815ae5a2 | ||
|
|
387f79ceae | ||
|
|
bae1259aba | ||
|
|
4ac1d91c16 | ||
|
|
81b8f3fc0e | ||
|
|
8541c87c97 | ||
|
|
0e4314ad1e | ||
|
|
6b44f217a3 | ||
|
|
76760e1bf3 | ||
|
|
d313a74266 | ||
|
|
a535ca31a8 | ||
|
|
da0bb80fb4 | ||
|
|
fb9026d12d | ||
|
|
4ae36ac727 | ||
|
|
ffab3a43fc | ||
|
|
e38d04c655 | ||
|
|
1923d6b1bc | ||
|
|
608a38f873 | ||
|
|
604ab79af9 | ||
|
|
4a43a6ae13 | ||
|
|
e9e0861eb7 | ||
|
|
477028a025 | ||
|
|
b90dcfb041 | ||
|
|
1790a389c7 | ||
|
|
deed9de3e7 | ||
|
|
9e748332dc | ||
|
|
749c2435ed | ||
|
|
6360609980 | ||
|
|
fed60ca61a | ||
|
|
f18f9e2dce | ||
|
|
e1b26bc76f | ||
|
|
2aada8f683 | ||
|
|
f9f386fa19 | ||
|
|
507a9a53ef | ||
|
|
c1ae637fa7 | ||
|
|
f967444ac2 | ||
|
|
c879307b8e | ||
|
|
c8ca3e643d | ||
|
|
9a648efa70 | ||
|
|
f45410c917 | ||
|
|
ec3f168a09 | ||
|
|
a9b35de7ee | ||
|
|
125d053b61 | ||
|
|
585cbf95a6 | ||
|
|
909565e5d9 | ||
|
|
a79423534c | ||
|
|
7a6fb5e35b | ||
|
|
6af34b66fb | ||
|
|
2974f7d11f | ||
|
|
edc0c89753 | ||
|
|
b1ff55dd06 | ||
|
|
f4b5422f66 | ||
|
|
d4ebace99f | ||
|
|
95e09c8e2a | ||
|
|
4623d59206 |
210
.dockerignore
Normal file
210
.dockerignore
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.run
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
typings
|
||||||
|
*Client.py
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
*_Spoiler.txt
|
||||||
|
*.bmbp
|
||||||
|
*.apbp
|
||||||
|
*.apl2ac
|
||||||
|
*.apm3
|
||||||
|
*.apmc
|
||||||
|
*.apz5
|
||||||
|
*.aptloz
|
||||||
|
*.apemerald
|
||||||
|
*.pyc
|
||||||
|
*.pyd
|
||||||
|
*.sfc
|
||||||
|
*.z64
|
||||||
|
*.n64
|
||||||
|
*.nes
|
||||||
|
*.smc
|
||||||
|
*.sms
|
||||||
|
*.gb
|
||||||
|
*.gbc
|
||||||
|
*.gba
|
||||||
|
*.wixobj
|
||||||
|
*.lck
|
||||||
|
*.db3
|
||||||
|
*multidata
|
||||||
|
*multisave
|
||||||
|
*.archipelago
|
||||||
|
*.apsave
|
||||||
|
*.BIN
|
||||||
|
*.puml
|
||||||
|
|
||||||
|
setups
|
||||||
|
build
|
||||||
|
bundle/components.wxs
|
||||||
|
dist
|
||||||
|
/prof/
|
||||||
|
README.html
|
||||||
|
.vs/
|
||||||
|
EnemizerCLI/
|
||||||
|
/Players/
|
||||||
|
/SNI/
|
||||||
|
/sni-*/
|
||||||
|
/appimagetool*
|
||||||
|
/host.yaml
|
||||||
|
/options.yaml
|
||||||
|
/config.yaml
|
||||||
|
/logs/
|
||||||
|
_persistent_storage.yaml
|
||||||
|
mystery_result_*.yaml
|
||||||
|
*-errors.txt
|
||||||
|
success.txt
|
||||||
|
output/
|
||||||
|
Output Logs/
|
||||||
|
/factorio/
|
||||||
|
/Minecraft Forge Server/
|
||||||
|
/WebHostLib/static/generated
|
||||||
|
/freeze_requirements.txt
|
||||||
|
/Archipelago.zip
|
||||||
|
/setup.ini
|
||||||
|
/installdelete.iss
|
||||||
|
/data/user.kv
|
||||||
|
/datapackage
|
||||||
|
/custom_worlds
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
installer.log
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# vim editor
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv*
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
/venv*/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
*.code-workspace
|
||||||
|
shell.nix
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# Cython intermediates
|
||||||
|
_speedups.c
|
||||||
|
_speedups.cpp
|
||||||
|
_speedups.html
|
||||||
|
|
||||||
|
# minecraft server stuff
|
||||||
|
jdk*/
|
||||||
|
minecraft*/
|
||||||
|
minecraft_versions.json
|
||||||
|
!worlds/minecraft/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
#undertale stuff
|
||||||
|
/Undertale/
|
||||||
|
|
||||||
|
# OS General Files
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Thumbs.db
|
||||||
|
[Dd]esktop.ini
|
||||||
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -19,7 +19,12 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGETOOL_VERSION: continuous
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
|
||||||
|
|
||||||
permissions: # permissions required for attestation
|
permissions: # permissions required for attestation
|
||||||
id-token: 'write'
|
id-token: 'write'
|
||||||
@@ -98,7 +103,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -134,10 +139,13 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
|
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||||
|
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -189,7 +197,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -9,7 +9,12 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGETOOL_VERSION: continuous
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
|
||||||
|
|
||||||
permissions: # permissions required for attestation
|
permissions: # permissions required for attestation
|
||||||
id-token: 'write'
|
id-token: 'write'
|
||||||
@@ -122,10 +127,13 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
|
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||||
|
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/unittests.yml
vendored
6
.github/workflows/unittests.yml
vendored
@@ -8,18 +8,24 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
265
BaseClasses.py
265
BaseClasses.py
@@ -5,12 +5,13 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import warnings
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque, defaultdict
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
@@ -153,17 +154,11 @@ class MultiWorld():
|
|||||||
self.algorithm = 'balanced'
|
self.algorithm = 'balanced'
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
self.regions = self.RegionManager(players)
|
self.regions = self.RegionManager(players)
|
||||||
self.shops = []
|
|
||||||
self.itempool = []
|
self.itempool = []
|
||||||
self.seed = None
|
self.seed = None
|
||||||
self.seed_name: str = "Unavailable"
|
self.seed_name: str = "Unavailable"
|
||||||
self.precollected_items = {player: [] for player in self.player_ids}
|
self.precollected_items = {player: [] for player in self.player_ids}
|
||||||
self.required_locations = []
|
self.required_locations = []
|
||||||
self.light_world_light_cone = False
|
|
||||||
self.dark_world_light_cone = False
|
|
||||||
self.rupoor_cost = 10
|
|
||||||
self.aga_randomness = True
|
|
||||||
self.save_and_quit_from_boss = True
|
|
||||||
self.custom = False
|
self.custom = False
|
||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
@@ -182,7 +177,7 @@ class MultiWorld():
|
|||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)")
|
"world's random object instead (usually self.random)", True)
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
@@ -227,17 +222,8 @@ class MultiWorld():
|
|||||||
self.seed_name = name if name else str(self.seed)
|
self.seed_name = name if name else str(self.seed)
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args: Namespace) -> None:
|
||||||
# TODO - remove this section once all worlds use options dataclasses
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
all_keys: Set[str] = {key for player in self.player_ids for key in
|
|
||||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
|
||||||
for option_key in all_keys:
|
|
||||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
|
||||||
f"Please use `self.options.{option_key}` instead.", True)
|
|
||||||
option.update(getattr(args, option_key, {}))
|
|
||||||
setattr(self, option_key, option)
|
|
||||||
|
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
@@ -438,12 +424,27 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||||
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||||
cached = getattr(self, "_all_state", None)
|
"""
|
||||||
if use_cache and cached:
|
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
|
||||||
return cached.copy()
|
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
|
||||||
|
it is able to reach, building as complete of a completed game state as possible.
|
||||||
|
|
||||||
|
:param use_cache: Deprecated and unused.
|
||||||
|
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
|
||||||
|
sweeping, such as before entrance randomization is complete.
|
||||||
|
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
|
||||||
|
state.
|
||||||
|
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
|
||||||
|
items it can.
|
||||||
|
|
||||||
|
:return: The completed CollectionState.
|
||||||
|
"""
|
||||||
|
if __debug__ and use_cache is not None:
|
||||||
|
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
|
||||||
|
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
|
||||||
|
DeprecationWarning)
|
||||||
ret = CollectionState(self, allow_partial_entrances)
|
ret = CollectionState(self, allow_partial_entrances)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
@@ -456,8 +457,6 @@ class MultiWorld():
|
|||||||
if perform_sweep:
|
if perform_sweep:
|
||||||
ret.sweep_for_advancements()
|
ret.sweep_for_advancements()
|
||||||
|
|
||||||
if use_cache:
|
|
||||||
self._all_state = ret
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
@@ -571,26 +570,9 @@ class MultiWorld():
|
|||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
base_locations = self.get_locations() if locations is None else locations
|
for _ in state.sweep_for_advancements(locations,
|
||||||
prog_locations = {location for location in base_locations if location.item
|
yield_each_sweep=True,
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
checked_locations=state.locations_checked):
|
||||||
|
|
||||||
while prog_locations:
|
|
||||||
sphere: Set[Location] = set()
|
|
||||||
# build up spheres of collection radius.
|
|
||||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
|
||||||
for location in prog_locations:
|
|
||||||
if location.can_reach(state):
|
|
||||||
sphere.add(location)
|
|
||||||
|
|
||||||
if not sphere:
|
|
||||||
# ran out of places and did not finish yet, quit
|
|
||||||
return False
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
prog_locations -= sphere
|
|
||||||
|
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -706,6 +688,12 @@ class MultiWorld():
|
|||||||
sphere.append(locations.pop(n))
|
sphere.append(locations.pop(n))
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
|
if __debug__:
|
||||||
|
from Fill import FillError
|
||||||
|
raise FillError(
|
||||||
|
f"Could not access required locations for accessibility check. Missing: {locations}",
|
||||||
|
multiworld=self,
|
||||||
|
)
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
logging.warning(f"Could not access required locations for accessibility check."
|
logging.warning(f"Could not access required locations for accessibility check."
|
||||||
f" Missing: {locations}")
|
f" Missing: {locations}")
|
||||||
@@ -869,20 +857,133 @@ class CollectionState():
|
|||||||
"Please switch over to sweep_for_advancements.")
|
"Please switch over to sweep_for_advancements.")
|
||||||
return self.sweep_for_advancements(locations)
|
return self.sweep_for_advancements(locations)
|
||||||
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
|
||||||
if locations is None:
|
yield_each_sweep: bool) -> Iterator[None]:
|
||||||
locations = self.multiworld.get_filled_locations()
|
"""
|
||||||
reachable_advancements = True
|
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
|
||||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
of a yield statement.
|
||||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
"""
|
||||||
|
all_players = {player for player, _ in advancements_per_player}
|
||||||
|
players_to_check = all_players
|
||||||
|
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
|
||||||
|
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
|
||||||
|
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
|
||||||
|
# sweep is finished.
|
||||||
|
checking_if_finished = False
|
||||||
|
while players_to_check:
|
||||||
|
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
|
||||||
|
next_players_to_check = set()
|
||||||
|
|
||||||
while reachable_advancements:
|
for player, locations in advancements_per_player:
|
||||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
if player not in players_to_check:
|
||||||
locations -= reachable_advancements
|
next_advancements_per_player.append((player, locations))
|
||||||
for advancement in reachable_advancements:
|
continue
|
||||||
self.advancements.add(advancement)
|
|
||||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
# Accessibility of each location is checked first because a player's region accessibility cache becomes
|
||||||
self.collect(advancement.item, True, advancement)
|
# stale whenever one of their own items is collected into the state.
|
||||||
|
reachable_locations: List[Location] = []
|
||||||
|
unreachable_locations: List[Location] = []
|
||||||
|
for location in locations:
|
||||||
|
if location.can_reach(self):
|
||||||
|
# Locations containing items that do not belong to `player` could be collected immediately
|
||||||
|
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
|
||||||
|
# items at reachable locations are collected in a single loop.
|
||||||
|
reachable_locations.append(location)
|
||||||
|
else:
|
||||||
|
unreachable_locations.append(location)
|
||||||
|
if unreachable_locations:
|
||||||
|
next_advancements_per_player.append((player, unreachable_locations))
|
||||||
|
|
||||||
|
# A previous player's locations processed in the current `while players_to_check` iteration could have
|
||||||
|
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
|
||||||
|
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
|
||||||
|
# their items is collected.
|
||||||
|
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
|
||||||
|
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
|
||||||
|
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
|
||||||
|
# more performant to instead discard `player` from `next_players_to_check` once their locations have
|
||||||
|
# been processed.
|
||||||
|
next_players_to_check.discard(player)
|
||||||
|
|
||||||
|
# Collect the items from the reachable locations.
|
||||||
|
for advancement in reachable_locations:
|
||||||
|
self.advancements.add(advancement)
|
||||||
|
item = advancement.item
|
||||||
|
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
|
||||||
|
if self.collect(item, True, advancement):
|
||||||
|
# The player the item belongs to may be able to reach additional locations in the next sweep
|
||||||
|
# iteration.
|
||||||
|
next_players_to_check.add(item.player)
|
||||||
|
|
||||||
|
if not next_players_to_check:
|
||||||
|
if not checking_if_finished:
|
||||||
|
# It is assumed that each player's world only logically depends on itself, which may not be the
|
||||||
|
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
|
||||||
|
checking_if_finished = True
|
||||||
|
next_players_to_check = all_players
|
||||||
|
else:
|
||||||
|
checking_if_finished = False
|
||||||
|
|
||||||
|
players_to_check = next_players_to_check
|
||||||
|
advancements_per_player = next_advancements_per_player
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
yield
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
|
||||||
|
yield_each_sweep: Literal[True],
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
|
||||||
|
yield_each_sweep: Literal[False] = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> None: ...
|
||||||
|
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
|
||||||
|
"""
|
||||||
|
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
|
||||||
|
until there are no more reachable locations that contain uncollected advancement items.
|
||||||
|
|
||||||
|
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
|
||||||
|
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
|
||||||
|
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
|
||||||
|
self.advancements when None.
|
||||||
|
"""
|
||||||
|
if checked_locations is None:
|
||||||
|
checked_locations = self.advancements
|
||||||
|
|
||||||
|
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
|
||||||
|
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
|
||||||
|
advancements_per_player: List[Tuple[int, List[Location]]]
|
||||||
|
if locations is None:
|
||||||
|
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
|
||||||
|
advancements_per_player = []
|
||||||
|
for player, locations_dict in self.multiworld.regions.location_cache.items():
|
||||||
|
filtered_locations = [location for location in locations_dict.values()
|
||||||
|
if location.advancement and location not in checked_locations]
|
||||||
|
if filtered_locations:
|
||||||
|
advancements_per_player.append((player, filtered_locations))
|
||||||
|
else:
|
||||||
|
# Filter and separate the locations into a list for each player.
|
||||||
|
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
|
||||||
|
for location in locations:
|
||||||
|
if location.advancement and location not in checked_locations:
|
||||||
|
advancements_per_player_dict[location.player].append(location)
|
||||||
|
# Convert to a list of tuples.
|
||||||
|
advancements_per_player = list(advancements_per_player_dict.items())
|
||||||
|
del advancements_per_player_dict
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
# Return a generator that will yield at the end of each sweep iteration.
|
||||||
|
return self._sweep_for_advancements_impl(advancements_per_player, True)
|
||||||
|
else:
|
||||||
|
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
|
||||||
|
# once started, then start and exhaust the generator by attempting to iterate it.
|
||||||
|
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
|
||||||
|
assert False, "Generator yielded when it should have run to completion without yielding"
|
||||||
|
return None
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -1150,13 +1251,13 @@ class Region:
|
|||||||
self.region_manager = region_manager
|
self.region_manager = region_manager
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Location:
|
def __getitem__(self, index: int) -> Location:
|
||||||
return self._list.__getitem__(index)
|
return self._list[index]
|
||||||
|
|
||||||
def __setitem__(self, index: int, value: Location) -> None:
|
def __setitem__(self, index: int, value: Location) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self._list.__len__()
|
return len(self._list)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return iter(self._list)
|
return iter(self._list)
|
||||||
@@ -1170,8 +1271,8 @@ class Region:
|
|||||||
|
|
||||||
class LocationRegister(Register):
|
class LocationRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
location: Location = self._list.__getitem__(index)
|
location: Location = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.location_cache[location.player][location.name])
|
del(self.region_manager.location_cache[location.player][location.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Location) -> None:
|
def insert(self, index: int, value: Location) -> None:
|
||||||
@@ -1182,8 +1283,8 @@ class Region:
|
|||||||
|
|
||||||
class EntranceRegister(Register):
|
class EntranceRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
entrance: Entrance = self._list.__getitem__(index)
|
entrance: Entrance = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Entrance) -> None:
|
def insert(self, index: int, value: Entrance) -> None:
|
||||||
@@ -1430,31 +1531,47 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b0000
|
filler = 0b00000
|
||||||
""" aka trash, as in filler items like ammo, currency etc """
|
""" aka trash, as in filler items like ammo, currency etc """
|
||||||
|
|
||||||
progression = 0b0001
|
progression = 0b00001
|
||||||
""" Item that is logically relevant.
|
""" Item that is logically relevant.
|
||||||
Protects this item from being placed on excluded or unreachable locations. """
|
Protects this item from being placed on excluded or unreachable locations. """
|
||||||
|
|
||||||
useful = 0b0010
|
useful = 0b00010
|
||||||
""" Item that is especially useful.
|
""" Item that is especially useful.
|
||||||
Protects this item from being placed on excluded or unreachable locations.
|
Protects this item from being placed on excluded or unreachable locations.
|
||||||
When combined with another flag like "progression", it means "an especially useful progression item". """
|
When combined with another flag like "progression", it means "an especially useful progression item". """
|
||||||
|
|
||||||
trap = 0b0100
|
trap = 0b00100
|
||||||
""" Item that is detrimental in some way. """
|
""" Item that is detrimental in some way. """
|
||||||
|
|
||||||
skip_balancing = 0b1000
|
skip_balancing = 0b01000
|
||||||
""" should technically never occur on its own
|
""" should technically never occur on its own
|
||||||
Item that is logically relevant, but progression balancing should not touch.
|
Item that is logically relevant, but progression balancing should not touch.
|
||||||
Typically currency or other counted items. """
|
|
||||||
|
Possible reasons for why an item should not be pulled ahead by progression balancing:
|
||||||
|
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
|
||||||
|
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
|
||||||
|
|
||||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
deprioritized = 0b10000
|
||||||
|
""" Should technically never occur on its own.
|
||||||
|
Will not be considered for priority locations,
|
||||||
|
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
||||||
|
|
||||||
|
Should be used for items that would feel bad for the player to find on a priority location.
|
||||||
|
Usually, these are items that are plentiful or insignificant. """
|
||||||
|
|
||||||
|
progression_deprioritized_skip_balancing = 0b11001
|
||||||
|
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
||||||
|
these items often want both flags. """
|
||||||
|
|
||||||
|
progression_skip_balancing = 0b01001 # only progression gets balanced
|
||||||
|
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
"""As Network API flag int."""
|
"""As Network API flag int."""
|
||||||
return int(self & 0b0111)
|
return int(self & 0b00111)
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
@@ -1498,6 +1615,10 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deprioritized(self) -> bool:
|
||||||
|
return ItemClassification.deprioritized in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filler(self) -> bool:
|
def filler(self) -> bool:
|
||||||
return not (self.advancement or self.useful or self.trap)
|
return not (self.advancement or self.useful or self.trap)
|
||||||
@@ -1805,7 +1926,7 @@ class Tutorial(NamedTuple):
|
|||||||
description: str
|
description: str
|
||||||
language: str
|
language: str
|
||||||
file_name: str
|
file_name: str
|
||||||
link: str
|
link: str # unused
|
||||||
authors: List[str]
|
authors: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
167
CommonClient.py
167
CommonClient.py
@@ -21,7 +21,7 @@ import Utils
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor, mark_raw
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_current_datapackage(self) -> dict[str, typing.Any]:
|
||||||
|
"""
|
||||||
|
Return datapackage for current game if known.
|
||||||
|
|
||||||
|
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
return {}
|
||||||
|
checksum = self.ctx.checksums[self.ctx.game]
|
||||||
|
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
|
||||||
|
|
||||||
def _cmd_missing(self, filter_text = "") -> bool:
|
def _cmd_missing(self, filter_text = "") -> bool:
|
||||||
"""List all missing location checks, from your local game state.
|
"""List all missing location checks, from your local game state.
|
||||||
Can be given text, which will be used as filter."""
|
Can be given text, which will be used as filter."""
|
||||||
@@ -107,7 +118,9 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
|
||||||
|
lookup = self.get_current_datapackage().get("location_name_to_id", {})
|
||||||
|
for location, location_id in lookup.items():
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -128,43 +141,91 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_items(self):
|
def output_datapackage_part(self, key: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Helper to digest a specific section of this game's datapackage.
|
||||||
|
|
||||||
|
:param key: The dictionary key in the datapackage.
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine {name}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
lookup = self.get_current_datapackage().get(key)
|
||||||
|
if lookup is None:
|
||||||
|
self.output("datapackage not yet loaded, try again")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.output(f"{name} for {self.ctx.game}")
|
||||||
|
for key in lookup:
|
||||||
|
self.output(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_items(self) -> bool:
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("item_name_to_id", "Item Names")
|
||||||
self.output("No game set, cannot determine existing items.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
|
||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
|
||||||
self.output(item_name)
|
|
||||||
|
|
||||||
def _cmd_item_groups(self):
|
def _cmd_locations(self) -> bool:
|
||||||
"""List all item group names for the currently running game."""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing item groups.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_locations(self):
|
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("location_name_to_id", "Location Names")
|
||||||
self.output("No game set, cannot determine existing locations.")
|
|
||||||
return False
|
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
|
||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
|
||||||
self.output(location_name)
|
|
||||||
|
|
||||||
def _cmd_location_groups(self):
|
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||||
"""List all location group names for the currently running game."""
|
filter_key: str,
|
||||||
if not self.ctx.game:
|
name: str) -> bool:
|
||||||
self.output("No game set, cannot determine existing location groups.")
|
"""
|
||||||
return False
|
Logs an item or location group from the player's game's datapackage.
|
||||||
self.output(f"Location Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_ready(self):
|
:param group_key: Either Item or Location group to be processed.
|
||||||
|
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine existing {name} Groups.")
|
||||||
|
return False
|
||||||
|
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
|
||||||
|
.get(self.ctx.game, {}).get(group_key, {})
|
||||||
|
if lookup is None:
|
||||||
|
self.output("datapackage not yet loaded, try again")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if filter_key:
|
||||||
|
if filter_key not in lookup:
|
||||||
|
self.output(f"Unknown {name} Group {filter_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.output(f"{name}s for {name} Group \"{filter_key}\"")
|
||||||
|
for entry in lookup[filter_key]:
|
||||||
|
self.output(entry)
|
||||||
|
else:
|
||||||
|
self.output(f"{name} Groups for {self.ctx.game}")
|
||||||
|
for group in lookup:
|
||||||
|
self.output(group)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_item_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all item group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("item_name_groups", key, "Item")
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_location_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all location group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("location_name_groups", key, "Location")
|
||||||
|
|
||||||
|
def _cmd_ready(self) -> bool:
|
||||||
"""Send ready status to server."""
|
"""Send ready status to server."""
|
||||||
self.ctx.ready = not self.ctx.ready
|
self.ctx.ready = not self.ctx.ready
|
||||||
if self.ctx.ready:
|
if self.ctx.ready:
|
||||||
@@ -174,6 +235,7 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
return True
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||||
@@ -201,6 +263,7 @@ class CommonContext:
|
|||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
|
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -210,7 +273,7 @@ class CommonContext:
|
|||||||
return iter(self._game_store)
|
return iter(self._game_store)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return self._game_store.__repr__()
|
return repr(self._game_store)
|
||||||
|
|
||||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||||
@@ -378,6 +441,8 @@ class CommonContext:
|
|||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
|
if self.game:
|
||||||
|
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
@@ -637,6 +702,24 @@ class CommonContext:
|
|||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
|
def consume_network_item_groups(self):
|
||||||
|
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
|
def consume_network_location_groups(self):
|
||||||
|
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
|
|
||||||
def set_notify(self, *keys: str) -> None:
|
def set_notify(self, *keys: str) -> None:
|
||||||
@@ -937,6 +1020,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.hint_points = args.get("hint_points", 0)
|
ctx.hint_points = args.get("hint_points", 0)
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.consume_players_package(args["players"])
|
||||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||||
|
if ctx.game:
|
||||||
|
game = ctx.game
|
||||||
|
else:
|
||||||
|
game = ctx.slot_info[ctx.slot][1]
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
msgs.append({"cmd": "LocationChecks",
|
msgs.append({"cmd": "LocationChecks",
|
||||||
@@ -1017,11 +1106,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.stored_data.update(args["keys"])
|
ctx.stored_data.update(args["keys"])
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
ctx.stored_data[args["key"]] = args["value"]
|
ctx.stored_data[args["key"]] = args["value"]
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
elif args["key"].startswith("EnergyLink"):
|
elif args["key"].startswith("EnergyLink"):
|
||||||
ctx.current_energy_link_value = args["value"]
|
ctx.current_energy_link_value = args["value"]
|
||||||
if ctx.ui:
|
if ctx.ui:
|
||||||
|
|||||||
100
Dockerfile
Normal file
100
Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# hadolint global ignore=SC1090,SC1091
|
||||||
|
|
||||||
|
# Source
|
||||||
|
FROM scratch AS release
|
||||||
|
WORKDIR /release
|
||||||
|
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
|
||||||
|
|
||||||
|
# Enemizer
|
||||||
|
FROM alpine:3.21 AS enemizer
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /release
|
||||||
|
COPY --from=release /release/Enemizer.zip .
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
apk add unzip=6.0-r15 --no-cache && \
|
||||||
|
unzip -u Enemizer.zip -d EnemizerCLI && \
|
||||||
|
chmod -R 777 EnemizerCLI; \
|
||||||
|
else touch EnemizerCLI; fi
|
||||||
|
|
||||||
|
# Cython builder stage
|
||||||
|
FROM python:3.12 AS cython-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
setuptools
|
||||||
|
|
||||||
|
COPY _speedups.pyx .
|
||||||
|
COPY intset.h .
|
||||||
|
|
||||||
|
RUN cythonize -b -i _speedups.pyx
|
||||||
|
|
||||||
|
# Archipelago
|
||||||
|
FROM python:3.12-slim AS archipelago
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install requirements
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
gcc=4:12.2.0-3 \
|
||||||
|
libc6-dev \
|
||||||
|
libtk8.6=8.6.13-2 \
|
||||||
|
g++=4:12.2.0-3 \
|
||||||
|
curl && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create and activate venv
|
||||||
|
RUN python -m venv $VIRTUAL_ENV; \
|
||||||
|
. $VIRTUAL_ENV/bin/activate
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
COPY --from=cython-builder /build/*.so ./
|
||||||
|
|
||||||
|
# Run ModuleUpdate
|
||||||
|
RUN python ModuleUpdate.py -y
|
||||||
|
|
||||||
|
# Purge unneeded packages
|
||||||
|
RUN apt-get purge -y \
|
||||||
|
git \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
g++ && \
|
||||||
|
apt-get autoremove -y
|
||||||
|
|
||||||
|
# Copy necessary components
|
||||||
|
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
cp -r /tmp/EnemizerCLI EnemizerCLI; \
|
||||||
|
fi; \
|
||||||
|
rm -rf /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# Define health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${PORT:-80} || exit 1
|
||||||
|
|
||||||
|
# Ensure no runtime ModuleUpdate.
|
||||||
|
ENV SKIP_REQUIREMENTS_UPDATE=true
|
||||||
|
|
||||||
|
ENTRYPOINT [ "python", "WebHost.py" ]
|
||||||
91
Fill.py
91
Fill.py
@@ -116,6 +116,13 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
if swap:
|
if swap:
|
||||||
|
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
||||||
|
# swap state, instead of sweeping from `base_state` each time.
|
||||||
|
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
|
||||||
|
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
||||||
|
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
||||||
|
max_swap_base_state_cache_length = 3
|
||||||
|
|
||||||
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||||
swap_attempts = ((i, location, unsafe)
|
swap_attempts = ((i, location, unsafe)
|
||||||
for unsafe in (False, True)
|
for unsafe in (False, True)
|
||||||
@@ -130,9 +137,30 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
|
||||||
multiworld.get_filled_locations(item.player)
|
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
||||||
if single_player_placement else None)
|
# If a state has already checked the location of the swap, then it cannot be used.
|
||||||
|
if location not in previous_safe_swap_state.advancements:
|
||||||
|
# Previous swap states will have collected all items in `item_pool`, so the new
|
||||||
|
# `swap_state` can skip having to collect them again.
|
||||||
|
# Previous swap states will also have already checked many locations, making the sweep
|
||||||
|
# faster.
|
||||||
|
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No previous swap_state was usable as a base state to sweep from, so create a new one.
|
||||||
|
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
# Unsafe states should not be added to the cache because they have collected `placed_item`.
|
||||||
|
if not unsafe:
|
||||||
|
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
|
||||||
|
# Remove the oldest cached state.
|
||||||
|
previous_safe_swap_state_cache.pop()
|
||||||
|
# Add the new state to the start of the cache.
|
||||||
|
previous_safe_swap_state_cache.appendleft(swap_state)
|
||||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
@@ -330,7 +358,12 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(multiworld: MultiWorld,
|
||||||
|
state: CollectionState,
|
||||||
|
locations: list[Location],
|
||||||
|
pool: list[Item] | None = None) -> None:
|
||||||
|
if pool is None:
|
||||||
|
pool = []
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if
|
minimal_players = {player for player in multiworld.player_ids if
|
||||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
@@ -450,6 +483,12 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||||
|
assert all(item.location is None for item in multiworld.itempool), (
|
||||||
|
"At the start of distribute_items_restrictive, "
|
||||||
|
"there are items in the multiworld itempool that are already placed on locations:\n"
|
||||||
|
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
|
||||||
|
)
|
||||||
|
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
multiworld.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -492,18 +531,48 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
single_player = multiworld.players == 1 and not multiworld.groups
|
single_player = multiworld.players == 1 and not multiworld.groups
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
|
regular_progression = []
|
||||||
|
deprioritized_progression = []
|
||||||
|
for item in progitempool:
|
||||||
|
if item.deprioritized:
|
||||||
|
deprioritized_progression.append(item)
|
||||||
|
else:
|
||||||
|
regular_progression.append(item)
|
||||||
|
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
||||||
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
|
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations and regular_progression:
|
||||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
||||||
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
||||||
name="Priority Retry", one_item_per_player=False)
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry", one_item_per_player=False, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# There are no more regular progression items that can be placed on any priority locations.
|
||||||
|
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# retry with deprioritized items AND without one_item_per_player optimisation
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 3", one_item_per_player=False)
|
||||||
|
|
||||||
|
# restore original order of progitempool
|
||||||
|
progitempool[:] = [item for item in progitempool if not item.location]
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past"
|
|||||||
WINDOW_MIN_HEIGHT = 525
|
WINDOW_MIN_HEIGHT = 525
|
||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
class AdjusterSubWorld(object):
|
class AdjusterSubWorld(object):
|
||||||
def __init__(self, random):
|
def __init__(self, random):
|
||||||
@@ -40,7 +41,6 @@ class AdjusterWorld(object):
|
|||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
|
||||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
def _get_help_string(self, action):
|
def _get_help_string(self, action):
|
||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
|
|
||||||
# See argparse.BooleanOptionalAction
|
# See argparse.BooleanOptionalAction
|
||||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
@@ -364,10 +365,10 @@ def run_sprite_update():
|
|||||||
logging.info("Done updating sprites")
|
logging.info("Done updating sprites")
|
||||||
|
|
||||||
|
|
||||||
def update_sprites(task, on_finish=None):
|
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
||||||
resultmessage = ""
|
resultmessage = ""
|
||||||
successful = True
|
successful = True
|
||||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
os.makedirs(sprite_dir, exist_ok=True)
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
|
|
||||||
@@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None):
|
|||||||
on_finish(successful, resultmessage)
|
on_finish(successful, resultmessage)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task.update_status("Downloading alttpr sprites list")
|
task.update_status("Downloading remote sprites list")
|
||||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
with urlopen(repository_url, context=ctx) as response:
|
||||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
successful = False
|
successful = False
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
return
|
return
|
||||||
@@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None):
|
|||||||
try:
|
try:
|
||||||
task.update_status("Determining needed sprites")
|
task.update_status("Determining needed sprites")
|
||||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
||||||
filename not in current_sprites]
|
filename not in current_sprites]
|
||||||
|
|
||||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||||
type(e).__name__, e)
|
type(e).__name__, e)
|
||||||
@@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None):
|
|||||||
successful = False
|
successful = False
|
||||||
|
|
||||||
if successful:
|
if successful:
|
||||||
resultmessage = "alttpr sprites updated successfully"
|
resultmessage = "Remote sprites updated successfully"
|
||||||
|
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
|
|
||||||
@@ -868,7 +869,7 @@ class SpriteSelector():
|
|||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
open_file(self.custom_sprite_dir)
|
open_file(self.custom_sprite_dir)
|
||||||
|
|
||||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
remote_frametitle = Label(self.window, text='Remote Sprites')
|
||||||
|
|
||||||
custom_frametitle = Frame(self.window)
|
custom_frametitle = Frame(self.window)
|
||||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||||
@@ -877,8 +878,8 @@ class SpriteSelector():
|
|||||||
title_link.pack(side=LEFT)
|
title_link.pack(side=LEFT)
|
||||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||||
|
|
||||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||||
if not randomOnEvent:
|
if not randomOnEvent:
|
||||||
@@ -891,11 +892,18 @@ class SpriteSelector():
|
|||||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
|
repository_label = Label(frame, text='Sprite Repository:')
|
||||||
|
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
||||||
|
repository_entry = Entry(frame, textvariable=self.repository_url)
|
||||||
|
|
||||||
|
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
||||||
|
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||||
button.pack(side=LEFT,padx=(0,5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||||
button.pack(side=LEFT, padx=(0, 5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
@@ -1055,7 +1063,7 @@ class SpriteSelector():
|
|||||||
for i, button in enumerate(frame.buttons):
|
for i, button in enumerate(frame.buttons):
|
||||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||||
|
|
||||||
def update_alttpr_sprites(self):
|
def update_remote_sprites(self):
|
||||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||||
self.window.destroy()
|
self.window.destroy()
|
||||||
self.parent.update()
|
self.parent.update()
|
||||||
@@ -1068,7 +1076,8 @@ class SpriteSelector():
|
|||||||
messagebox.showerror("Sprite Updater", resultmessage)
|
messagebox.showerror("Sprite Updater", resultmessage)
|
||||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||||
|
|
||||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
||||||
|
on_finish, self.repository_url.get())
|
||||||
|
|
||||||
def browse_for_sprite(self):
|
def browse_for_sprite(self):
|
||||||
sprite = filedialog.askopenfilename(
|
sprite = filedialog.askopenfilename(
|
||||||
@@ -1158,12 +1167,13 @@ class SpriteSelector():
|
|||||||
os.makedirs(self.custom_sprite_dir)
|
os.makedirs(self.custom_sprite_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alttpr_sprite_dir(self):
|
def remote_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "alttpr")
|
return user_path("data", "sprites", "alttp", "remote")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "custom")
|
return user_path("data", "sprites", "alttp", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ async def gba_sync_task(ctx: MMBN3Context):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
53
Main.py
53
Main.py
@@ -1,10 +1,11 @@
|
|||||||
import collections
|
import collections
|
||||||
|
from collections.abc import Mapping
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
|
|
||||||
@@ -12,8 +13,9 @@ import worlds
|
|||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||||
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||||
|
from NetUtils import convert_to_base_types
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple
|
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -92,6 +94,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
del local_early
|
del local_early
|
||||||
del early
|
del early
|
||||||
|
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||||
|
|
||||||
|
# Clear non-applicable local and non-local items.
|
||||||
|
if multiworld.players == 1:
|
||||||
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
logger.info('Creating MultiWorld.')
|
logger.info('Creating MultiWorld.')
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
AutoWorld.call_all(multiworld, "create_regions")
|
||||||
|
|
||||||
@@ -99,12 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(multiworld, "create_items")
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
# items can't be both local and non-local, prefer local
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
AutoWorld.call_all(multiworld, "set_rules")
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
@@ -125,11 +130,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
|
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
|
||||||
if multiworld.players > 1:
|
if multiworld.players > 1:
|
||||||
locality_rules(multiworld)
|
locality_rules(multiworld)
|
||||||
else:
|
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
|
||||||
|
|
||||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||||
|
|
||||||
@@ -173,7 +176,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
multiworld.link_items()
|
multiworld.link_items()
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(world.options.item_links for world in multiworld.worlds.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
@@ -238,11 +241,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
from NetUtils import HintStatus
|
from NetUtils import HintStatus
|
||||||
slot_data = {}
|
slot_data: dict[int, Mapping[str, Any]] = {}
|
||||||
client_versions = {}
|
client_versions: dict[int, tuple[int, int, int]] = {}
|
||||||
games = {}
|
games: dict[int, str] = {}
|
||||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
minimum_versions: NetUtils.MinimumVersions = {
|
||||||
slot_info = {}
|
"server": AutoWorld.World.required_server_version, "clients": client_versions
|
||||||
|
}
|
||||||
|
slot_info: dict[int, NetUtils.NetworkSlot] = {}
|
||||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||||
@@ -257,7 +262,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
group_members=sorted(group["players"]))
|
group_members=sorted(group["players"]))
|
||||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||||
for player, world_precollected in multiworld.precollected_items.items()}
|
for player, world_precollected in multiworld.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
precollected_hints: dict[int, set[NetUtils.Hint]] = {
|
||||||
|
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
|
||||||
|
}
|
||||||
|
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
@@ -314,7 +321,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
if current_sphere:
|
if current_sphere:
|
||||||
spheres.append(dict(current_sphere))
|
spheres.append(dict(current_sphere))
|
||||||
|
|
||||||
multidata = {
|
multidata: NetUtils.MultiData | bytes = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||||
@@ -324,7 +331,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
"er_hint_data": er_hint_data,
|
"er_hint_data": er_hint_data,
|
||||||
"precollected_items": precollected_items,
|
"precollected_items": precollected_items,
|
||||||
"precollected_hints": precollected_hints,
|
"precollected_hints": precollected_hints,
|
||||||
"version": tuple(version_tuple),
|
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.seed_name,
|
||||||
@@ -332,9 +339,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
"race_mode": int(multiworld.is_race),
|
"race_mode": int(multiworld.is_race),
|
||||||
}
|
}
|
||||||
|
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
for key in ("slot_data", "er_hint_data"):
|
||||||
|
multidata[key] = convert_to_base_types(multidata[key])
|
||||||
|
|
||||||
|
multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||||
|
|
||||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||||
f.write(bytes([3])) # version of format
|
f.write(bytes([3])) # version of format
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ elif sys.version_info < (3, 10, 1):
|
|||||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
_skip_update = bool(
|
||||||
|
getattr(sys, "frozen", False) or
|
||||||
|
multiprocessing.parent_process() or
|
||||||
|
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
|
||||||
|
)
|
||||||
update_ran = _skip_update
|
update_ran = _skip_update
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import NetUtils
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore, Hint, HintStatus
|
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||||
from BaseClasses import ItemClassification
|
from BaseClasses import ItemClassification
|
||||||
|
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ class Context:
|
|||||||
raise Utils.VersionException("Incompatible multidata.")
|
raise Utils.VersionException("Incompatible multidata.")
|
||||||
return restricted_loads(zlib.decompress(data[1:]))
|
return restricted_loads(zlib.decompress(data[1:]))
|
||||||
|
|
||||||
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
|
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
|
||||||
use_embedded_server_options: bool):
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
@@ -546,6 +546,7 @@ class Context:
|
|||||||
|
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
try:
|
try:
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
encoded_save = pickle.dumps(self.get_save())
|
encoded_save = pickle.dumps(self.get_save())
|
||||||
with open(self.save_filename, "wb") as f:
|
with open(self.save_filename, "wb") as f:
|
||||||
f.write(zlib.compress(encoded_save))
|
f.write(zlib.compress(encoded_save))
|
||||||
@@ -752,7 +753,7 @@ class Context:
|
|||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||||
recipients: typing.Sequence[int] = None):
|
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||||
@@ -767,8 +768,9 @@ class Context:
|
|||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
|
|
||||||
# only remember hints that were not already found at the time of creation
|
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
|
||||||
if not hint.found:
|
# For LocationScouts use-cases, all hints should be remembered
|
||||||
|
if not hint.found or persist_even_if_found:
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
# we can check once if hint already exists
|
# we can check once if hint already exists
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
@@ -1946,10 +1948,52 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
|
elif cmd == 'CreateHints':
|
||||||
|
location_player = args.get("player", client.slot)
|
||||||
|
locations = args["locations"]
|
||||||
|
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if location_player != client.slot and location not in ctx.locations[location_player]:
|
||||||
|
error_text = (
|
||||||
|
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
|
||||||
|
"Please refrain from hinting other slot's locations that you don't know contain your items."
|
||||||
|
)
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
target_item, item_player, flags = ctx.locations[location_player][location]
|
||||||
|
|
||||||
|
if client.slot not in ctx.slot_set(item_player):
|
||||||
|
if status != HintStatus.HINT_UNSPECIFIED:
|
||||||
|
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
if client.slot != location_player:
|
||||||
|
error_text = "CreateHints: Can only create hints for own items or own locations."
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
|
||||||
|
|
||||||
|
# As of writing this code, only_new=True does not update status for existing hints
|
||||||
|
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
elif cmd == 'UpdateHint':
|
elif cmd == 'UpdateHint':
|
||||||
location = args["location"]
|
location = args["location"]
|
||||||
|
|||||||
60
NetUtils.py
60
NetUtils.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
import warnings
|
||||||
@@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
group_members: Sequence[int] = () # only populated if type == group
|
||||||
|
|
||||||
|
|
||||||
class NetworkItem(typing.NamedTuple):
|
class NetworkItem(typing.NamedTuple):
|
||||||
@@ -106,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_base_types(obj: typing.Any) -> _base_types:
|
||||||
|
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||||
|
return tuple(convert_to_base_types(o) for o in obj)
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
|
||||||
|
elif obj is None or type(obj) in (str, int, float, bool):
|
||||||
|
return obj
|
||||||
|
# unwrap simple types to their base, such as StrEnum
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, int):
|
||||||
|
return int(obj)
|
||||||
|
elif isinstance(obj, float):
|
||||||
|
return float(obj)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Cannot handle {type(obj)}")
|
||||||
|
|
||||||
|
|
||||||
_encode = JSONEncoder(
|
_encode = JSONEncoder(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
check_circular=False,
|
check_circular=False,
|
||||||
@@ -450,6 +472,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
location_id not in checked])
|
location_id not in checked])
|
||||||
|
|
||||||
|
|
||||||
|
class MinimumVersions(typing.TypedDict):
|
||||||
|
server: tuple[int, int, int]
|
||||||
|
clients: dict[int, tuple[int, int, int]]
|
||||||
|
|
||||||
|
|
||||||
|
class GamesPackage(typing.TypedDict, total=False):
|
||||||
|
item_name_groups: dict[str, list[str]]
|
||||||
|
item_name_to_id: dict[str, int]
|
||||||
|
location_name_groups: dict[str, list[str]]
|
||||||
|
location_name_to_id: dict[str, int]
|
||||||
|
checksum: str
|
||||||
|
|
||||||
|
|
||||||
|
class DataPackage(typing.TypedDict):
|
||||||
|
games: dict[str, GamesPackage]
|
||||||
|
|
||||||
|
|
||||||
|
class MultiData(typing.TypedDict):
|
||||||
|
slot_data: dict[int, Mapping[str, typing.Any]]
|
||||||
|
slot_info: dict[int, NetworkSlot]
|
||||||
|
connect_names: dict[str, tuple[int, int]]
|
||||||
|
locations: dict[int, dict[int, tuple[int, int, int]]]
|
||||||
|
checks_in_area: dict[int, dict[str, int | list[int]]]
|
||||||
|
server_options: dict[str, object]
|
||||||
|
er_hint_data: dict[int, dict[int, str]]
|
||||||
|
precollected_items: dict[int, list[int]]
|
||||||
|
precollected_hints: dict[int, set[Hint]]
|
||||||
|
version: tuple[int, int, int]
|
||||||
|
tags: list[str]
|
||||||
|
minimum_versions: MinimumVersions
|
||||||
|
seed_name: str
|
||||||
|
spheres: list[dict[int, set[int]]]
|
||||||
|
datapackage: dict[str, GamesPackage]
|
||||||
|
race_mode: int
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
41
Options.py
41
Options.py
@@ -494,6 +494,30 @@ class Choice(NumericOption):
|
|||||||
else:
|
else:
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
def __lt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} < {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__lt__(other)
|
||||||
|
|
||||||
|
def __gt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} > {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__gt__(other)
|
||||||
|
|
||||||
|
def __le__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__le__(other)
|
||||||
|
|
||||||
|
def __ge__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__ge__(other)
|
||||||
|
|
||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
@@ -865,13 +889,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||||
|
|
||||||
def __getitem__(self, item: str) -> typing.Any:
|
def __getitem__(self, item: str) -> typing.Any:
|
||||||
return self.value.__getitem__(item)
|
return self.value[item]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
def __iter__(self) -> typing.Iterator[str]:
|
||||||
return self.value.__iter__()
|
return iter(self.value)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
# __getitem__ fallback fails for Counters, so we define this explicitly
|
# __getitem__ fallback fails for Counters, so we define this explicitly
|
||||||
def __contains__(self, item) -> bool:
|
def __contains__(self, item) -> bool:
|
||||||
@@ -1067,10 +1091,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
yield from self.value
|
yield from self.value
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
class ConnectionsMeta(AssembleOptions):
|
||||||
@@ -1094,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple):
|
|||||||
|
|
||||||
entrance: str
|
entrance: str
|
||||||
exit: str
|
exit: str
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
||||||
percentage: int = 100
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -1217,7 +1241,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
connection.exit) for connection in value])
|
connection.exit) for connection in value])
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||||
yield from self.value
|
yield from self.value
|
||||||
@@ -1315,6 +1339,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
will be returned as a sorted list.
|
will be returned as a sorted list.
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
|
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name not in type(self).type_hints:
|
if option_name not in type(self).type_hints:
|
||||||
@@ -1643,7 +1668,7 @@ class OptionGroup(typing.NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
||||||
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
|
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
|
||||||
"""
|
"""
|
||||||
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
||||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ Currently, the following games are supported:
|
|||||||
* Super Metroid
|
* Super Metroid
|
||||||
* Secret of Evermore
|
* Secret of Evermore
|
||||||
* Final Fantasy
|
* Final Fantasy
|
||||||
* Rogue Legacy
|
|
||||||
* VVVVVV
|
* VVVVVV
|
||||||
* Raft
|
* Raft
|
||||||
* Super Mario 64
|
* Super Mario 64
|
||||||
@@ -41,7 +40,6 @@ Currently, the following games are supported:
|
|||||||
* The Messenger
|
* The Messenger
|
||||||
* Kingdom Hearts 2
|
* Kingdom Hearts 2
|
||||||
* The Legend of Zelda: Link's Awakening DX
|
* The Legend of Zelda: Link's Awakening DX
|
||||||
* Clique
|
|
||||||
* Adventure
|
* Adventure
|
||||||
* DLC Quest
|
* DLC Quest
|
||||||
* Noita
|
* Noita
|
||||||
@@ -82,6 +80,7 @@ Currently, the following games are supported:
|
|||||||
* Jak and Daxter: The Precursor Legacy
|
* Jak and Daxter: The Precursor Legacy
|
||||||
* Super Mario Land 2: 6 Golden Coins
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
* shapez
|
* shapez
|
||||||
|
* Paint
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from settings import Settings
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -285,7 +286,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
sni_path = Settings.sni_options.sni_path
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = Settings.sni_options.snes_rom_start
|
||||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
36
Utils.py
36
Utils.py
@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.2"
|
__version__ = "0.6.3"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -413,13 +413,23 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
|||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
common_path = cache_path("common.json")
|
||||||
|
if os.path.exists(common_path):
|
||||||
|
with open(common_path) as f:
|
||||||
|
common_file = json.load(f)
|
||||||
|
uuid = common_file.get("uuid", None)
|
||||||
|
else:
|
||||||
|
common_file = {}
|
||||||
|
uuid = None
|
||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
import uuid
|
from uuid import uuid4
|
||||||
uuid = uuid.getnode()
|
uuid = str(uuid4())
|
||||||
persistent_store("client", "uuid", uuid)
|
common_file["uuid"] = uuid
|
||||||
|
with open(common_path, "w") as f:
|
||||||
|
json.dump(common_file, f, separators=(",", ":"))
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -442,6 +452,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
# used by OptionCounter
|
# used by OptionCounter
|
||||||
|
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
|
||||||
if module == "collections" and name == "Counter":
|
if module == "collections" and name == "Counter":
|
||||||
return collections.Counter
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
@@ -472,6 +483,18 @@ def restricted_loads(s: bytes) -> Any:
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
|
def restricted_dumps(obj: Any) -> bytes:
|
||||||
|
"""Helper function analogous to pickle.dumps()."""
|
||||||
|
s = pickle.dumps(obj)
|
||||||
|
# Assert that the string can be successfully loaded by restricted_loads
|
||||||
|
try:
|
||||||
|
restricted_loads(s)
|
||||||
|
except pickle.UnpicklingError as e:
|
||||||
|
raise pickle.PicklingError(e) from e
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
class ByValue:
|
class ByValue:
|
||||||
"""
|
"""
|
||||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||||
@@ -930,8 +953,7 @@ def _extend_freeze_support() -> None:
|
|||||||
# Handle the first process that MP will create
|
# Handle the first process that MP will create
|
||||||
if (
|
if (
|
||||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
'from multiprocessing.resource_tracker import main',
|
||||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
|
||||||
'from multiprocessing.forkserver import main'
|
'from multiprocessing.forkserver import main'
|
||||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||||
):
|
):
|
||||||
|
|||||||
48
WebHost.py
48
WebHost.py
@@ -54,16 +54,15 @@ def get_app() -> "Flask":
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
def copy_tutorials_files_to_static() -> None:
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
zfile: zipfile.ZipInfo
|
zfile: zipfile.ZipInfo
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
worlds = {}
|
worlds = {}
|
||||||
data = []
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||||
for game, world in worlds.items():
|
for game, world in worlds.items():
|
||||||
# copy files from world's docs folder to the generated folder
|
# copy files from world's docs folder to the generated folder
|
||||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
if world.zip_path:
|
if world.zip_path:
|
||||||
@@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for zfile in zf.infolist():
|
for zfile in zf.infolist():
|
||||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
zfile.filename = os.path.basename(zfile.filename)
|
zfile.filename = os.path.basename(zfile.filename)
|
||||||
zf.extract(zfile, target_path)
|
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
||||||
|
f.write(zf.read(zfile))
|
||||||
else:
|
else:
|
||||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
files = os.listdir(source_path)
|
files = os.listdir(source_path)
|
||||||
for file in files:
|
for file in files:
|
||||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
shutil.copyfile(Utils.local_path(source_path, file),
|
||||||
|
Utils.local_path(target_path, secure_filename(file)))
|
||||||
# build a json tutorial dict per game
|
|
||||||
game_data = {'gameTitle': game, 'tutorials': []}
|
|
||||||
for tutorial in world.web.tutorials:
|
|
||||||
# build dict for the json file
|
|
||||||
current_tutorial = {
|
|
||||||
'name': tutorial.tutorial_name,
|
|
||||||
'description': tutorial.description,
|
|
||||||
'files': [{
|
|
||||||
'language': tutorial.language,
|
|
||||||
'filename': game + '/' + tutorial.file_name,
|
|
||||||
'link': f'{game}/{tutorial.link}',
|
|
||||||
'authors': tutorial.authors
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
# check if the name of the current guide exists already
|
|
||||||
for guide in game_data['tutorials']:
|
|
||||||
if guide and tutorial.tutorial_name == guide['name']:
|
|
||||||
guide['files'].append(current_tutorial['files'][0])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
game_data['tutorials'].append(current_tutorial)
|
|
||||||
|
|
||||||
data.append(game_data)
|
|
||||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
|
||||||
generic_data = {}
|
|
||||||
for games in data:
|
|
||||||
if 'Archipelago' in games['gameTitle']:
|
|
||||||
generic_data = data.pop(data.index(games))
|
|
||||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
|
||||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
|
||||||
return sorted_data
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -142,7 +110,7 @@ if __name__ == "__main__":
|
|||||||
logging.warning("Could not update LttP sprites.")
|
logging.warning("Could not update LttP sprites.")
|
||||||
app = get_app()
|
app = get_app()
|
||||||
create_options_files()
|
create_options_files()
|
||||||
create_ordered_tutorials_file()
|
copy_tutorials_files_to_static()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
autohost(app.config)
|
autohost(app.config)
|
||||||
if app.config["SELFGEN"]:
|
if app.config["SELFGEN"]:
|
||||||
|
|||||||
@@ -61,30 +61,43 @@ cache = Cache()
|
|||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
|
def to_python(value):
|
||||||
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
|
|
||||||
|
def to_url(value):
|
||||||
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
return to_python(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
return to_url(value)
|
||||||
|
|
||||||
|
|
||||||
# short UUID
|
# short UUID
|
||||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
app.jinja_env.filters["suuid"] = to_url
|
||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from werkzeug.utils import find_modules
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
|
||||||
|
|
||||||
|
for module in find_modules("WebHostLib", include_packages=True):
|
||||||
|
importlib.import_module(module)
|
||||||
|
|
||||||
|
from . import api
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
|
from Utils import restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
@@ -56,7 +56,7 @@ def generate_api():
|
|||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
else:
|
else:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
# convert to json compatible
|
# convert to json compatible
|
||||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||||
owner=session["_id"])
|
owner=session["_id"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from flask import abort, url_for
|
from flask import abort, url_for
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
from ..models import Room
|
from ..models import Room
|
||||||
@@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
|
|||||||
downloads.append(slot_download)
|
downloads.append(slot_download)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
"players": get_players(room.seed),
|
"players": get_players(room.seed),
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import Room, Seed
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ def get_rooms():
|
|||||||
response = []
|
response = []
|
||||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"room_id": room.id,
|
"room_id": to_url(room.id),
|
||||||
"seed_id": room.seed.id,
|
"seed_id": to_url(room.seed.id),
|
||||||
"creation_time": room.creation_time,
|
"creation_time": room.creation_time,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"timeout": room.timeout,
|
"timeout": room.timeout,
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ def get_seeds():
|
|||||||
response = []
|
response = []
|
||||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"seed_id": seed.id,
|
"seed_id": to_url(seed.id),
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed),
|
"players": get_players(seed),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -164,9 +164,6 @@ def autogen(config: dict):
|
|||||||
Thread(target=keep_running, name="AP_Autogen").start()
|
Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
|
|
||||||
|
|
||||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MultiworldInstance():
|
class MultiworldInstance():
|
||||||
def __init__(self, config: dict, id: int):
|
def __init__(self, config: dict, id: int):
|
||||||
self.room_ids = set()
|
self.room_ids = set()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import base64
|
import base64
|
||||||
from typing import Union, Dict, Set, Tuple
|
from collections.abc import Set
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, render_template
|
from flask import request, flash, redirect, url_for, render_template
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
||||||
options = {}
|
options = {}
|
||||||
for uploaded_file in files:
|
for uploaded_file in files:
|
||||||
if banned_file(uploaded_file.filename):
|
if banned_file(uploaded_file.filename):
|
||||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: dict[str, dict | str],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
tuple[dict[str, str | bool], dict[str, dict]]:
|
||||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||||
results = {}
|
results: dict[str, str | bool] = {}
|
||||||
rolled_results = {}
|
rolled_results: dict[str, dict] = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
try:
|
try:
|
||||||
if type(text) is dict:
|
if type(text) is dict:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class WebHostContext(Context):
|
|||||||
else:
|
else:
|
||||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
game_data_packages[game] = restricted_loads(row.data)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
@@ -159,6 +159,7 @@ class WebHostContext(Context):
|
|||||||
@db_session
|
@db_session
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
room = Room.get(id=self.room_id)
|
room = Room.get(id=self.room_id)
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import random
|
import random
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, List, Optional, Union, Set
|
from pickle import PicklingError
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
from flask import flash, redirect, render_template, request, session, url_for
|
||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
|
|||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name
|
from Generate import PlandoOptions, handle_name
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__
|
from Utils import __version__, restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
@@ -23,8 +23,8 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
|||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
|
||||||
plando_options: Set[str] = set()
|
plando_options: set[str] = set()
|
||||||
for substr in ("bosses", "items", "connections", "texts"):
|
for substr in ("bosses", "items", "connections", "texts"):
|
||||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||||
plando_options.add(substr)
|
plando_options.add(substr)
|
||||||
@@ -73,7 +73,7 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
|
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
@@ -83,12 +83,18 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
gen = Generation(
|
try:
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
gen = Generation(
|
||||||
# convert to json compatible
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
meta=json.dumps(meta),
|
# convert to json compatible
|
||||||
state=STATE_QUEUED,
|
meta=json.dumps(meta),
|
||||||
owner=session["_id"])
|
state=STATE_QUEUED,
|
||||||
|
owner=session["_id"])
|
||||||
|
except PicklingError as e:
|
||||||
|
from .autolauncher import handle_generation_failure
|
||||||
|
handle_generation_failure(e)
|
||||||
|
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
|
|
||||||
return redirect(url_for("wait_seed", seed=gen.id))
|
return redirect(url_for("wait_seed", seed=gen.id))
|
||||||
@@ -104,9 +110,9 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
return redirect(url_for("view_seed", seed=seed_id))
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
||||||
if not meta:
|
if meta is None:
|
||||||
meta: Dict[str, Any] = {}
|
meta = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
|||||||
from LttPAdjuster import update_sprites
|
from LttPAdjuster import update_sprites
|
||||||
|
|
||||||
# Target directories
|
# Target directories
|
||||||
input_dir = user_path("data", "sprites", "alttpr")
|
input_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||||
|
|
||||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||||
|
|||||||
@@ -7,17 +7,69 @@ from flask import request, redirect, url_for, render_template, Response, session
|
|||||||
from pony.orm import count, commit, db_session
|
from pony.orm import count, commit, db_session
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister, World
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
|
from Utils import title_sorted
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str) -> str:
|
||||||
if game_name in AutoWorldRegister.world_types:
|
if game_name in AutoWorldRegister.world_types:
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
return 'grass'
|
return 'grass'
|
||||||
|
|
||||||
|
|
||||||
|
def get_visible_worlds() -> dict[str, type(World)]:
|
||||||
|
worlds = {}
|
||||||
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
|
return worlds
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(path: str) -> str:
|
||||||
|
import mistune
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
markdown = mistune.create_markdown(
|
||||||
|
escape=False,
|
||||||
|
plugins=[
|
||||||
|
"strikethrough",
|
||||||
|
"footnotes",
|
||||||
|
"table",
|
||||||
|
"speedup",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
heading_id_count: Counter[str] = Counter()
|
||||||
|
|
||||||
|
def heading_id(text: str) -> str:
|
||||||
|
nonlocal heading_id_count
|
||||||
|
import re # there is no good way to do this without regex
|
||||||
|
|
||||||
|
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||||
|
n = heading_id_count[s]
|
||||||
|
heading_id_count[s] += 1
|
||||||
|
if n > 0:
|
||||||
|
s += f"-{n}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||||
|
for tok in state.tokens:
|
||||||
|
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||||
|
text = tok["text"]
|
||||||
|
assert isinstance(text, str)
|
||||||
|
unique_id = heading_id(text)
|
||||||
|
tok["attrs"]["id"] = unique_id
|
||||||
|
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||||
|
|
||||||
|
markdown.before_render_hooks.append(id_hook)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
|
document = f.read()
|
||||||
|
return markdown(document)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||||
def page_not_found(err):
|
def page_not_found(err):
|
||||||
@@ -31,83 +83,94 @@ def start_playing():
|
|||||||
return render_template(f"startPlaying.html")
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
# Game Info Pages
|
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
|
"""Game Info Pages"""
|
||||||
try:
|
try:
|
||||||
world = AutoWorldRegister.world_types[game]
|
theme = get_world_theme(game)
|
||||||
if lang not in world.web.game_info_languages:
|
secure_game_name = secure_filename(game)
|
||||||
raise KeyError("Sorry, this game's info page is not available in that language yet.")
|
lang = secure_filename(lang)
|
||||||
except KeyError:
|
document = render_markdown(os.path.join(
|
||||||
|
app.static_folder, "generated", "docs",
|
||||||
|
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||||
|
))
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
|
||||||
|
|
||||||
|
|
||||||
# List of supported games
|
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def games():
|
def games():
|
||||||
worlds = {}
|
"""List of supported games"""
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("supportedGames.html", worlds=worlds)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial(game, file, lang):
|
def tutorial(game: str, file: str):
|
||||||
try:
|
try:
|
||||||
world = AutoWorldRegister.world_types[game]
|
theme = get_world_theme(game)
|
||||||
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
|
secure_game_name = secure_filename(game)
|
||||||
raise KeyError("Sorry, the tutorial is not available in that language yet.")
|
file = secure_filename(file)
|
||||||
except KeyError:
|
document = render_markdown(os.path.join(
|
||||||
|
app.static_folder, "generated", "docs",
|
||||||
|
secure_game_name, file+".md"
|
||||||
|
))
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
return render_template("tutorialLanding.html")
|
tutorials = {}
|
||||||
|
worlds = AutoWorldRegister.world_types
|
||||||
|
for world_name, world_type in worlds.items():
|
||||||
|
current_world = tutorials[world_name] = {}
|
||||||
|
for tutorial in world_type.web.tutorials:
|
||||||
|
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
||||||
|
"description": tutorial.description, "files": {}})
|
||||||
|
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
||||||
|
"authors": tutorial.authors,
|
||||||
|
"language": tutorial.language
|
||||||
|
}
|
||||||
|
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
||||||
|
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
||||||
|
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def faq(lang: str):
|
def faq(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Frequently Asked Questions",
|
title="Frequently Asked Questions",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def glossary(lang: str):
|
def glossary(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Glossary",
|
title="Glossary",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ Flask-Compress>=1.17
|
|||||||
Flask-Limiter>=3.12
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.6.3
|
bokeh>=3.6.3
|
||||||
markupsafe>=3.0.2
|
markupsafe>=3.0.2
|
||||||
Markdown>=3.7
|
|
||||||
mdx-breakless-lists>=1.0.1
|
|
||||||
setproctitle>=1.3.5
|
setproctitle>=1.3.5
|
||||||
|
mistune>=3.1.3
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const gameInfo = document.getElementById('game-info');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, this game's info page is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the info page.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
|
||||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const tutorialWrapper = document.getElementById('tutorial-wrapper');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, the tutorial is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the tutorial.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
|
||||||
if (title) {
|
|
||||||
document.title = title.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
const showError = () => {
|
|
||||||
const tutorial = document.getElementById('tutorial-landing');
|
|
||||||
document.getElementById('page-title').innerText = 'This page is out of logic!';
|
|
||||||
tutorial.removeChild(document.getElementById('loading'));
|
|
||||||
const userMessage = document.createElement('h3');
|
|
||||||
const homepageLink = document.createElement('a');
|
|
||||||
homepageLink.innerText = 'Click here';
|
|
||||||
homepageLink.setAttribute('href', '/');
|
|
||||||
userMessage.append(homepageLink);
|
|
||||||
userMessage.append(' to go back to safety!');
|
|
||||||
tutorial.append(userMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
const tutorialDiv = document.getElementById('tutorial-landing');
|
|
||||||
if (ajax.status !== 200) { return showError(); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const games = JSON.parse(ajax.responseText);
|
|
||||||
games.forEach((game) => {
|
|
||||||
const gameTitle = document.createElement('h2');
|
|
||||||
gameTitle.innerText = game.gameTitle;
|
|
||||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
|
||||||
tutorialDiv.appendChild(gameTitle);
|
|
||||||
|
|
||||||
game.tutorials.forEach((tutorial) => {
|
|
||||||
const tutorialName = document.createElement('h3');
|
|
||||||
tutorialName.innerText = tutorial.name;
|
|
||||||
tutorialDiv.appendChild(tutorialName);
|
|
||||||
|
|
||||||
const tutorialDescription = document.createElement('p');
|
|
||||||
tutorialDescription.innerText = tutorial.description;
|
|
||||||
tutorialDiv.appendChild(tutorialDescription);
|
|
||||||
|
|
||||||
const intro = document.createElement('p');
|
|
||||||
intro.innerText = 'This guide is available in the following languages:';
|
|
||||||
tutorialDiv.appendChild(intro);
|
|
||||||
|
|
||||||
const fileList = document.createElement('ul');
|
|
||||||
tutorial.files.forEach((file) => {
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.innerText = file.language;
|
|
||||||
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
|
|
||||||
listItem.appendChild(anchor);
|
|
||||||
|
|
||||||
listItem.append(' by ');
|
|
||||||
for (let author of file.authors) {
|
|
||||||
listItem.append(author);
|
|
||||||
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
|
|
||||||
listItem.append(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
tutorialDiv.appendChild(fileList);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tutorialDiv.removeChild(document.getElementById('loading'));
|
|
||||||
} catch (error) {
|
|
||||||
showError();
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we are on an anchor when coming in, and scroll to it.
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (hash) {
|
|
||||||
const offset = 128; // To account for navbar banner at top of page.
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
|
||||||
window.scrollTo(rect.left, rect.top - offset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
|
||||||
ajax.send();
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import typing
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
@@ -18,21 +17,23 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
|
||||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
|
||||||
games_played = defaultdict(Counter)
|
total_games: Counter[str] = Counter()
|
||||||
total_games = Counter()
|
|
||||||
cutoff = date.today() - timedelta(days=30)
|
cutoff = date.today() - timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
if slot.game in known_games:
|
if slot.game in known_games:
|
||||||
total_games[slot.game] += 1
|
current_game = slot.game
|
||||||
games_played[room.creation_time.date()][slot.game] += 1
|
else:
|
||||||
|
current_game = "Other"
|
||||||
|
total_games[current_game] += 1
|
||||||
|
games_played[room.creation_time.date()][current_game] += 1
|
||||||
return total_games, games_played
|
return total_games, games_played
|
||||||
|
|
||||||
|
|
||||||
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
def get_color_palette(colors_needed: int) -> list[RGB]:
|
||||||
colors = []
|
colors = []
|
||||||
# colors_needed +1 to prevent first and last color being too close to each other
|
# colors_needed +1 to prevent first and last color being too close to each other
|
||||||
colors_needed += 1
|
colors_needed += 1
|
||||||
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
|
||||||
game: str, color: RGB) -> figure:
|
|
||||||
occurences = []
|
occurences = []
|
||||||
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||||
for day in days:
|
for day in days:
|
||||||
@@ -84,7 +84,7 @@ def stats():
|
|||||||
days = sorted(games_played)
|
days = sorted(games_played)
|
||||||
|
|
||||||
color_palette = get_color_palette(len(total_games))
|
color_palette = get_color_palette(len(total_games))
|
||||||
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||||
|
|
||||||
for game in sorted(total_games):
|
for game in sorted(total_games):
|
||||||
occurences = []
|
occurences = []
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ game }} Info</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% include 'header/'+theme+'Header.html' %}
|
|
||||||
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
|
|
||||||
<!-- Populated my JS / MD -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
|
{% elif patch.game == "Factorio" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Factorio Mod...</a>
|
||||||
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% set theme_name = theme|default("grass", true) %}
|
||||||
|
{% include "header/"+theme_name+"Header.html" %}
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% include 'header/'+theme+'Header.html' %}
|
|
||||||
<title>Archipelago</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
|
|
||||||
<!-- Content generated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,14 +3,32 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% include 'header/grassHeader.html' %}
|
||||||
<title>Archipelago Guides</title>
|
<title>Archipelago Guides</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
|
<div id="tutorial-landing" class="markdown">
|
||||||
<h1 id="page-title">Archipelago Guides</h1>
|
<h1>Archipelago Guides</h1>
|
||||||
<p id="loading">Loading...</p>
|
{% for world_name, world_type in worlds.items() %}
|
||||||
|
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
|
||||||
|
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
|
||||||
|
<h3>{{ tutorial_name }}</h3>
|
||||||
|
<p>{{ tutorial_data.description }}</p>
|
||||||
|
<p>This guide is available in the following languages:</p>
|
||||||
|
<ul>
|
||||||
|
{% for file_name, file_data in tutorial_data.files.items() %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
|
||||||
|
by
|
||||||
|
{% for author in file_data.authors %}
|
||||||
|
{{ author }}
|
||||||
|
{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
import typing
|
import typing
|
||||||
@@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError
|
|||||||
import schema
|
import schema
|
||||||
|
|
||||||
import MultiServer
|
import MultiServer
|
||||||
from NetUtils import SlotType
|
from NetUtils import GamesPackage, SlotType
|
||||||
from Utils import VersionException, __version__
|
from Utils import VersionException, __version__
|
||||||
from worlds import GamesPackage
|
|
||||||
from worlds.Files import AutoPatchRegister
|
from worlds.Files import AutoPatchRegister
|
||||||
from worlds.AutoWorld import data_package_checksum
|
from worlds.AutoWorld import data_package_checksum
|
||||||
from . import app
|
from . import app
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ requires:
|
|||||||
|
|
||||||
{{ yaml_dump(game) }}:
|
{{ yaml_dump(game) }}:
|
||||||
{%- for group_name, group_options in option_groups.items() %}
|
{%- for group_name, group_options in option_groups.items() %}
|
||||||
# {{ group_name }}
|
##{% for _ in group_name %}#{% endfor %}##
|
||||||
|
# {{ group_name }} #
|
||||||
|
##{% for _ in group_name %}#{% endfor %}##
|
||||||
|
|
||||||
{%- for option_key, option in group_options.items() %}
|
{%- for option_key, option in group_options.items() %}
|
||||||
{{ option_key }}:
|
{{ option_key }}:
|
||||||
|
|||||||
61
deploy/docker-compose.yml
Normal file
61
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
multiworld:
|
||||||
|
# Build only once. Web service uses the same image build
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
# Name image for use in web service
|
||||||
|
image: archipelago-base
|
||||||
|
# Use locally-built image
|
||||||
|
pull_policy: never
|
||||||
|
# Launch main process without website hosting (config override)
|
||||||
|
entrypoint: python WebHost.py --config_override selflaunch.yaml
|
||||||
|
volumes:
|
||||||
|
# Mount application volume
|
||||||
|
- app_volume:/app
|
||||||
|
|
||||||
|
# Mount configs
|
||||||
|
- ./example_config.yaml:/app/config.yaml
|
||||||
|
- ./example_selflaunch.yaml:/app/selflaunch.yaml
|
||||||
|
|
||||||
|
# Expose on host network for access to dynamically mapped ports
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
# No Healthcheck in place yet for multiworld
|
||||||
|
healthcheck:
|
||||||
|
test: ["NONE"]
|
||||||
|
web:
|
||||||
|
# Use image build by multiworld service
|
||||||
|
image: archipelago-base
|
||||||
|
# Use locally-built image
|
||||||
|
pull_policy: never
|
||||||
|
# Launch gunicorn targeting WebHost application
|
||||||
|
entrypoint: gunicorn -c gunicorn.conf.py
|
||||||
|
volumes:
|
||||||
|
# Mount application volume
|
||||||
|
- app_volume:/app
|
||||||
|
|
||||||
|
# Mount configs
|
||||||
|
- ./example_config.yaml:/app/config.yaml
|
||||||
|
- ./example_gunicorn.conf.py:/app/gunicorn.conf.py
|
||||||
|
environment:
|
||||||
|
# Bind gunicorn on 8000
|
||||||
|
- PORT=8000
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:stable-alpine
|
||||||
|
volumes:
|
||||||
|
# Mount application volume
|
||||||
|
- app_volume:/app
|
||||||
|
|
||||||
|
# Mount config
|
||||||
|
- ./example_nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
ports:
|
||||||
|
# Nginx listening internally on port 80 -- mapped to 8080 on host
|
||||||
|
- 8080:80
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Share application directory amongst multiworld and web services
|
||||||
|
# (for access to log files and the like), and nginx (for static files)
|
||||||
|
app_volume:
|
||||||
10
deploy/example_config.yaml
Normal file
10
deploy/example_config.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Refer to ../docs/webhost configuration sample.yaml
|
||||||
|
|
||||||
|
# We'll be hosting VIA gunicorn
|
||||||
|
SELFHOST: false
|
||||||
|
# We'll start a separate process for rooms and generators
|
||||||
|
SELFLAUNCH: false
|
||||||
|
|
||||||
|
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
|
||||||
|
# Set as your local IP (192.168.x.x) to serve over LAN.
|
||||||
|
HOST_ADDRESS: localhost
|
||||||
19
deploy/example_gunicorn.conf.py
Normal file
19
deploy/example_gunicorn.conf.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
workers = 2
|
||||||
|
threads = 2
|
||||||
|
wsgi_app = "WebHost:get_app()"
|
||||||
|
accesslog = "-"
|
||||||
|
access_log_format = (
|
||||||
|
'%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||||
|
)
|
||||||
|
worker_class = "gthread" # "sync" | "gthread"
|
||||||
|
forwarded_allow_ips = "*"
|
||||||
|
loglevel = "info"
|
||||||
|
|
||||||
|
"""
|
||||||
|
You can programatically set values.
|
||||||
|
For example, set number of workers to half of the cpu count:
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
workers = multiprocessing.cpu_count() / 2
|
||||||
|
"""
|
||||||
64
deploy/example_nginx.conf
Normal file
64
deploy/example_nginx.conf
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
user nobody nogroup;
|
||||||
|
# 'user nobody nobody;' for systems with 'nobody' as a group instead
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024; # increase if you have lots of clients
|
||||||
|
accept_mutex off; # set to 'on' if nginx worker_processes > 1
|
||||||
|
# 'use epoll;' to enable for Linux 2.6+
|
||||||
|
# 'use kqueue;' to enable for FreeBSD, OSX
|
||||||
|
use epoll;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
# fallback in case we can't determine a type
|
||||||
|
default_type application/octet-stream;
|
||||||
|
access_log /var/log/nginx/access.log combined;
|
||||||
|
sendfile on;
|
||||||
|
|
||||||
|
upstream app_server {
|
||||||
|
# fail_timeout=0 means we always retry an upstream even if it failed
|
||||||
|
# to return a good HTTP response
|
||||||
|
|
||||||
|
# for UNIX domain socket setups
|
||||||
|
# server unix:/tmp/gunicorn.sock fail_timeout=0;
|
||||||
|
|
||||||
|
# for a TCP configuration
|
||||||
|
server web:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
# use 'listen 80 deferred;' for Linux
|
||||||
|
# use 'listen 80 accept_filter=httpready;' for FreeBSD
|
||||||
|
listen 80 deferred;
|
||||||
|
client_max_body_size 4G;
|
||||||
|
|
||||||
|
# set the correct host(s) for your site
|
||||||
|
# server_name example.com www.example.com;
|
||||||
|
|
||||||
|
keepalive_timeout 5;
|
||||||
|
|
||||||
|
# path for static files
|
||||||
|
root /app/WebHostLib;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
# checks for static file, if not found proxy to app
|
||||||
|
try_files $uri @proxy_to_app;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @proxy_to_app {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
# we don't want nginx trying to do something clever with
|
||||||
|
# redirects, we set the Host: header above already.
|
||||||
|
proxy_redirect off;
|
||||||
|
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
deploy/example_selflaunch.yaml
Normal file
13
deploy/example_selflaunch.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Refer to ../docs/webhost configuration sample.yaml
|
||||||
|
|
||||||
|
# We'll be hosting VIA gunicorn
|
||||||
|
SELFHOST: false
|
||||||
|
# Start room and generator processes
|
||||||
|
SELFLAUNCH: true
|
||||||
|
JOB_THRESHOLD: 0
|
||||||
|
|
||||||
|
# Maximum concurrent world gens
|
||||||
|
GENERATORS: 3
|
||||||
|
|
||||||
|
# Rooms will be spread across multiple processes
|
||||||
|
HOSTERS: 4
|
||||||
@@ -48,9 +48,6 @@
|
|||||||
# Civilization VI
|
# Civilization VI
|
||||||
/worlds/civ6/ @hesto2
|
/worlds/civ6/ @hesto2
|
||||||
|
|
||||||
# Clique
|
|
||||||
/worlds/clique/ @ThePhar
|
|
||||||
|
|
||||||
# Dark Souls III
|
# Dark Souls III
|
||||||
/worlds/dark_souls_3/ @Marechal-L @nex3
|
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||||
|
|
||||||
@@ -139,6 +136,9 @@
|
|||||||
# Overcooked! 2
|
# Overcooked! 2
|
||||||
/worlds/overcooked2/ @toasterparty
|
/worlds/overcooked2/ @toasterparty
|
||||||
|
|
||||||
|
# Paint
|
||||||
|
/worlds/paint/ @MarioManTAW
|
||||||
|
|
||||||
# Pokemon Emerald
|
# Pokemon Emerald
|
||||||
/worlds/pokemon_emerald/ @Zunawe
|
/worlds/pokemon_emerald/ @Zunawe
|
||||||
|
|
||||||
@@ -148,9 +148,6 @@
|
|||||||
# Raft
|
# Raft
|
||||||
/worlds/raft/ @SunnyBat
|
/worlds/raft/ @SunnyBat
|
||||||
|
|
||||||
# Rogue Legacy
|
|
||||||
/worlds/rogue_legacy/ @ThePhar
|
|
||||||
|
|
||||||
# Risk of Rain 2
|
# Risk of Rain 2
|
||||||
/worlds/ror2/ @kindasneaki
|
/worlds/ror2/ @kindasneaki
|
||||||
|
|
||||||
@@ -203,7 +200,7 @@
|
|||||||
/worlds/timespinner/ @Jarno458
|
/worlds/timespinner/ @Jarno458
|
||||||
|
|
||||||
# The Legend of Zelda (1)
|
# The Legend of Zelda (1)
|
||||||
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
|
/worlds/tloz/ @Rosalie-A
|
||||||
|
|
||||||
# TUNIC
|
# TUNIC
|
||||||
/worlds/tunic/ @silent-destroyer @ScipioWright
|
/worlds/tunic/ @silent-destroyer @ScipioWright
|
||||||
|
|||||||
92
docs/deploy using containers.md
Normal file
92
docs/deploy using containers.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Deploy Using Containers
|
||||||
|
|
||||||
|
If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version.
|
||||||
|
To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md).
|
||||||
|
Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy.
|
||||||
|
|
||||||
|
|
||||||
|
## Building the Container Image
|
||||||
|
|
||||||
|
What you'll need:
|
||||||
|
* A container runtime engine such as:
|
||||||
|
* [Docker](https://www.docker.com/) (Version 23.0 or later)
|
||||||
|
* [Podman](https://podman.io/) (version 4.0 or later)
|
||||||
|
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
|
||||||
|
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
|
||||||
|
|
||||||
|
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
|
||||||
|
`docker build -t archipelago .`
|
||||||
|
Or:
|
||||||
|
`podman build -t archipelago .`
|
||||||
|
|
||||||
|
It is recommended to tag the image using `-t` to more easily identify the image and run it.
|
||||||
|
|
||||||
|
|
||||||
|
## Running the Container
|
||||||
|
|
||||||
|
Running the container can be performed using:
|
||||||
|
`docker run --network host archipelago`
|
||||||
|
Or:
|
||||||
|
`podman run --network host archipelago`
|
||||||
|
|
||||||
|
The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`.
|
||||||
|
|
||||||
|
Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially:
|
||||||
|
`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml`
|
||||||
|
See `docs/webhost configuration sample.yaml` for example.
|
||||||
|
|
||||||
|
|
||||||
|
## Using Docker Compose
|
||||||
|
|
||||||
|
An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container.
|
||||||
|
|
||||||
|
To deploy in this manner, from the ["deploy"](../deploy) directory, run:
|
||||||
|
`docker compose up -d`
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
The `docker-compose.yaml` file defines three services:
|
||||||
|
* multiworld:
|
||||||
|
* Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service.
|
||||||
|
* web:
|
||||||
|
* Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it.
|
||||||
|
* We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service.
|
||||||
|
* No ports are exposed through to the host.
|
||||||
|
* nginx:
|
||||||
|
* Serves as a reverse proxy with `web` as its upstream.
|
||||||
|
* Directs all HTTP traffic from port 80 to the upstream service.
|
||||||
|
* Exposed to the host on port 8080. This is where we can reach the website.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network.
|
||||||
|
|
||||||
|
The configuration files may be modified to handle for machine-specific optimizations, such as:
|
||||||
|
* Web pages responding too slowly
|
||||||
|
* Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count.
|
||||||
|
* Game generation stalls
|
||||||
|
* Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml)
|
||||||
|
* Gameplay lags
|
||||||
|
* Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml)
|
||||||
|
|
||||||
|
Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`.
|
||||||
|
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||||
|
|
||||||
|
|
||||||
|
## Optional: A Link to the Past Enemizer
|
||||||
|
|
||||||
|
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||||
|
error if it is required.
|
||||||
|
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
|
||||||
|
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
|
||||||
|
Enemizer is not currently available for `aarch64`.
|
||||||
|
|
||||||
|
|
||||||
|
## Optional: Git
|
||||||
|
|
||||||
|
Building the image requires a local copy of the ArchipelagoMW source code.
|
||||||
|
Refer to [Running From Source](running%20from%20source.md#optional-git).
|
||||||
@@ -125,10 +125,8 @@ flowchart LR
|
|||||||
NM[Mod with Archipelago.MultiClient.Net]
|
NM[Mod with Archipelago.MultiClient.Net]
|
||||||
subgraph FNA/XNA
|
subgraph FNA/XNA
|
||||||
TS[Timespinner]
|
TS[Timespinner]
|
||||||
RL[Rogue Legacy]
|
|
||||||
end
|
end
|
||||||
NM <-- TsRandomizer --> TS
|
NM <-- TsRandomizer --> TS
|
||||||
NM <-- RogueLegacyRandomizer --> RL
|
|
||||||
subgraph Unity
|
subgraph Unity
|
||||||
ROR[Risk of Rain 2]
|
ROR[Risk of Rain 2]
|
||||||
SN[Subnautica]
|
SN[Subnautica]
|
||||||
@@ -177,4 +175,4 @@ flowchart LR
|
|||||||
FMOD <--> FMAPI
|
FMOD <--> FMAPI
|
||||||
end
|
end
|
||||||
CC <-- Integrated --> FC
|
CC <-- Integrated --> FC
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl
|
|||||||
* [Sync](#Sync)
|
* [Sync](#Sync)
|
||||||
* [LocationChecks](#LocationChecks)
|
* [LocationChecks](#LocationChecks)
|
||||||
* [LocationScouts](#LocationScouts)
|
* [LocationScouts](#LocationScouts)
|
||||||
|
* [CreateHints](#CreateHints)
|
||||||
* [UpdateHint](#UpdateHint)
|
* [UpdateHint](#UpdateHint)
|
||||||
* [StatusUpdate](#StatusUpdate)
|
* [StatusUpdate](#StatusUpdate)
|
||||||
* [Say](#Say)
|
* [Say](#Say)
|
||||||
@@ -294,7 +295,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
|
|||||||
| password | str | If the game session requires a password, it should be passed here. |
|
| password | str | If the game session requires a password, it should be passed here. |
|
||||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||||
| name | str | The player name for this client. |
|
| name | str | The player name for this client. |
|
||||||
| uuid | str | Unique identifier for player client. |
|
| uuid | str | Unique identifier for player. Cached in the user cache \Archipelago\Cache\common.json |
|
||||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||||
@@ -339,7 +340,8 @@ Sent to the server to retrieve the items that are on a specified list of locatio
|
|||||||
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
|
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
|
||||||
|
|
||||||
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
|
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
|
||||||
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
|
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
|
||||||
|
Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code.
|
||||||
|
|
||||||
#### Arguments
|
#### Arguments
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
@@ -347,6 +349,21 @@ This is useful in cases where an item appears in the game world, such as 'ledge
|
|||||||
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
||||||
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
||||||
|
|
||||||
|
### CreateHints
|
||||||
|
|
||||||
|
Sent to the server to create hints for a specified list of locations.
|
||||||
|
Hints that already exist will be silently skipped and their status will not be updated.
|
||||||
|
|
||||||
|
When creating hints for another slot's locations, the packet will fail if any of those locations don't contain items for the requesting slot.
|
||||||
|
When creating hints for your own slot's locations, non-existing locations will silently be skipped.
|
||||||
|
|
||||||
|
#### Arguments
|
||||||
|
| Name | Type | Notes |
|
||||||
|
| ---- | ---- | ----- |
|
||||||
|
| locations | list\[int\] | The ids of the locations to create hints for. |
|
||||||
|
| player | int | The ID of the player whose locations are being hinted for. Defaults to the requesting slot. |
|
||||||
|
| status | [HintStatus](#HintStatus) | If included, sets the status of the hint to this status. Defaults to `HINT_UNSPECIFIED`. Cannot set `HINT_FOUND`. |
|
||||||
|
|
||||||
### UpdateHint
|
### UpdateHint
|
||||||
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
|
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
|
||||||
|
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
|
|||||||
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
||||||
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
||||||
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
||||||
`worlds.alttp.options.py`
|
`worlds/alttp/Options.py`
|
||||||
|
|
||||||
### OptionDict
|
### OptionDict
|
||||||
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
||||||
|
|||||||
@@ -181,10 +181,3 @@ circular / partial imports. Instead, the code should fetch from settings on dema
|
|||||||
|
|
||||||
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
|
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
|
||||||
"global" settings could be used in global scope of worlds.
|
"global" settings could be used in global scope of worlds.
|
||||||
|
|
||||||
|
|
||||||
### APWorld Backwards Compatibility
|
|
||||||
|
|
||||||
APWorlds that want to be compatible with both stable and dev versions, have two options:
|
|
||||||
1. use the old Utils.get_options() API until Archipelago 0.4.2 is out
|
|
||||||
2. add some sort of compatibility code to your world that mimics the new API
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
* New classes, attributes, and methods in core code should have docstrings that follow
|
* New classes, attributes, and methods in core code should have docstrings that follow
|
||||||
[reST style](https://peps.python.org/pep-0287/).
|
[reST style](https://peps.python.org/pep-0287/).
|
||||||
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.
|
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.
|
||||||
|
* [Match statements](https://docs.python.org/3/tutorial/controlflow.html#tut-match)
|
||||||
|
may be used instead of `if`-`elif` if they result in nicer code, or they actually use pattern matching.
|
||||||
|
Beware of the performance: they are not `goto`s, but `if`-`elif` under the hood, and you may have less control. When
|
||||||
|
in doubt, just don't use it.
|
||||||
|
|
||||||
## Markdown
|
## Markdown
|
||||||
|
|
||||||
|
|||||||
347
docs/webhost api.md
Normal file
347
docs/webhost api.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# API Guide
|
||||||
|
|
||||||
|
Archipelago has a rudimentary API that can be queried by endpoints. The API is a work-in-progress and should be improved over time.
|
||||||
|
|
||||||
|
The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>`
|
||||||
|
|
||||||
|
The returned data will be formated in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
|
||||||
|
|
||||||
|
Current endpoints:
|
||||||
|
- Datapackage API
|
||||||
|
- [`/datapackage`](#datapackage)
|
||||||
|
- [`/datapackage/<string:checksum>`](#datapackagestringchecksum)
|
||||||
|
- [`/datapackage_checksum`](#datapackagechecksum)
|
||||||
|
- Generation API
|
||||||
|
- [`/generate`](#generate)
|
||||||
|
- [`/status/<suuid:seed>`](#status)
|
||||||
|
- Room API
|
||||||
|
- [`/room_status/<suuid:room_id>`](#roomstatus)
|
||||||
|
- User API
|
||||||
|
- [`/get_rooms`](#getrooms)
|
||||||
|
- [`/get_seeds`](#getseeds)
|
||||||
|
|
||||||
|
|
||||||
|
## Datapackage Endpoints
|
||||||
|
These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations.
|
||||||
|
|
||||||
|
### `/datapackage`
|
||||||
|
<a name="datapackage"></a>
|
||||||
|
Fetches the current datapackage from the WebHost.
|
||||||
|
You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago.
|
||||||
|
Each game will have:
|
||||||
|
- A checksum `checksum`
|
||||||
|
- A dict of item groups `item_name_groups`
|
||||||
|
- Item name to AP ID dict `item_name_to_id`
|
||||||
|
- A dict of location groups `location_name_groups`
|
||||||
|
- Location name to AP ID dict `location_name_to_id`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"games": {
|
||||||
|
...
|
||||||
|
"Clique": {
|
||||||
|
"checksum": "0271f7a80b44ba72187f92815c2bc8669cb464c7",
|
||||||
|
"item_name_groups": {
|
||||||
|
"Everything": [
|
||||||
|
"A Cool Filler Item (No Satisfaction Guaranteed)",
|
||||||
|
"Button Activation",
|
||||||
|
"Feeling of Satisfaction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"item_name_to_id": {
|
||||||
|
"A Cool Filler Item (No Satisfaction Guaranteed)": 69696967,
|
||||||
|
"Button Activation": 69696968,
|
||||||
|
"Feeling of Satisfaction": 69696969
|
||||||
|
},
|
||||||
|
"location_name_groups": {
|
||||||
|
"Everywhere": [
|
||||||
|
"The Big Red Button",
|
||||||
|
"The Item on the Desk"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"location_name_to_id": {
|
||||||
|
"The Big Red Button": 69696969,
|
||||||
|
"The Item on the Desk": 69696968
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/datapackage/<string:checksum>`
|
||||||
|
<a name="datapackagestringchecksum"></a>
|
||||||
|
Fetches a single datapackage by checksum.
|
||||||
|
Returns a dict of the game's data with:
|
||||||
|
- A checksum `checksum`
|
||||||
|
- A dict of item groups `item_name_groups`
|
||||||
|
- Item name to AP ID dict `item_name_to_id`
|
||||||
|
- A dict of location groups `location_name_groups`
|
||||||
|
- Location name to AP ID dict `location_name_to_id`
|
||||||
|
|
||||||
|
Its format will be identical to the whole-datapackage endpoint (`/datapackage`), except you'll only be returned the single game's data in a dict.
|
||||||
|
|
||||||
|
### `/datapackage_checksum`
|
||||||
|
<a name="datapackagechecksum"></a>
|
||||||
|
Fetches the checksums of the current static datapackages on the WebHost.
|
||||||
|
You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games.
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
|
||||||
|
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Generation Endpoint
|
||||||
|
These endpoints are used internally for the WebHost to generate games and validate their generation. They are also used by external applications to generate games automatically.
|
||||||
|
|
||||||
|
### `/generate`
|
||||||
|
<a name="generate"></a>
|
||||||
|
Submits a game to the WebHost for generation.
|
||||||
|
**This endpoint only accepts a POST HTTP request.**
|
||||||
|
|
||||||
|
There are two ways to submit data for generation: With a file and with JSON.
|
||||||
|
|
||||||
|
#### With a file:
|
||||||
|
Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/generate` endpoint.
|
||||||
|
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
|
||||||
|
|
||||||
|
Example using the python requests library:
|
||||||
|
```
|
||||||
|
file = {'file': open('Games.zip', 'rb')}
|
||||||
|
req = requests.post("https://archipelago.gg/api/generate", files=file)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### With JSON:
|
||||||
|
Compile your weights/yaml data into a dict. Then insert that into a dict with the key `"weights"`.
|
||||||
|
Finally, submit a POST request to the `/generate` endpoint.
|
||||||
|
If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response))
|
||||||
|
|
||||||
|
Example using the python requests library:
|
||||||
|
```
|
||||||
|
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
|
||||||
|
weights={"weights": data}
|
||||||
|
req = requests.post("https://archipelago.gg/api/generate", json=weights)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generation Response:
|
||||||
|
##### Successful Generation:
|
||||||
|
Upon successful generation, you'll be sent a JSON dict response detailing the generation:
|
||||||
|
- The UUID of the generation `detail`
|
||||||
|
- The SUUID of the generation `encoded`
|
||||||
|
- The response text `text`
|
||||||
|
- The page that will resolve to the seed/room generation page once generation has completed `url`
|
||||||
|
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
|
||||||
|
"encoded": "GYePFlpYS3aqt9a_OL6UYw",
|
||||||
|
"text": "Generation of seed 19878f16-5a58-4b76-aab7-d6bf38be9463 started successfully.",
|
||||||
|
"url": "http://archipelago.gg/wait/GYePFlpYS3aqt9a_OL6UYw",
|
||||||
|
"wait_api_url": "http://archipelago.gg/api/status/GYePFlpYS3aqt9a_OL6UYw"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Failed Generation:
|
||||||
|
|
||||||
|
Upon failed generation, you'll be returned a single key-value pair. The key will always be `text`
|
||||||
|
The value will give you a hint as to what may have gone wrong.
|
||||||
|
- Options without tags, and a 400 status code
|
||||||
|
- Options in a string, and a 400 status code
|
||||||
|
- Invalid file/weight string, `No options found. Expected file attachment or json weights.` with a 400 status code
|
||||||
|
- Too many slots for the server to process, `Max size of multiworld exceeded` with a 409 status code
|
||||||
|
|
||||||
|
If the generation detects a issue in generation, you'll be sent a dict with two key-value pairs (`text` and `detail`) and a 400 status code. The values will be:
|
||||||
|
- Summary of issue in `text`
|
||||||
|
- Detailed issue in `detail`
|
||||||
|
|
||||||
|
In the event of an unhandled server exception, you'll be provided a dict with a single key `text`:
|
||||||
|
- Exception, `Uncought Exception: <error>` with a 500 status code
|
||||||
|
|
||||||
|
### `/status/<suuid:seed>`
|
||||||
|
<a name="status"></a>
|
||||||
|
Retrieves the status of the seed's generation.
|
||||||
|
This endpoint will return a dict with a single key-vlaue pair. The key will always be `text`
|
||||||
|
The value will tell you the status of the generation:
|
||||||
|
- Generation was completed: `Generation done` with a 201 status code
|
||||||
|
- Generation request was not found: `Generation not found` with a 404 status code
|
||||||
|
- Generation of the seed failed: `Generation failed` with a 500 status code
|
||||||
|
- Generation is in progress still: `Generation running` with a 202 status code
|
||||||
|
|
||||||
|
## Room Endpoints
|
||||||
|
Endpoints to fetch information of the active WebHost room with the supplied room_ID.
|
||||||
|
|
||||||
|
### `/room_status/<suuid:room_id>`
|
||||||
|
<a name="roomstatus"></a>
|
||||||
|
Will provide a dict of room data with the following keys:
|
||||||
|
- Tracker SUUID (`tracker`)
|
||||||
|
- A list of players (`players`)
|
||||||
|
- Each item containing a list with the Slot name and Game
|
||||||
|
- Last known hosted port (`last_port`)
|
||||||
|
- Last activity timestamp (`last_activity`)
|
||||||
|
- The room timeout counter (`timeout`)
|
||||||
|
- A list of downloads for files required for gameplay (`downloads`)
|
||||||
|
- Each item is a dict containings the download URL and slot (`slot`, `download`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"downloads": [
|
||||||
|
{
|
||||||
|
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/1",
|
||||||
|
"slot": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/2",
|
||||||
|
"slot": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/3",
|
||||||
|
"slot": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/4",
|
||||||
|
"slot": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/5",
|
||||||
|
"slot": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_activity": "Fri, 18 Apr 2025 20:35:45 GMT",
|
||||||
|
"last_port": 52122,
|
||||||
|
"players": [
|
||||||
|
[
|
||||||
|
"Slot_Name_1",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_2",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_3",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_4",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_5",
|
||||||
|
"Ocarina of Time"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"timeout": 7200,
|
||||||
|
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Endpoints
|
||||||
|
User endpoints can get room and seed details from the current session tokens (cookies)
|
||||||
|
|
||||||
|
### `/get_rooms`
|
||||||
|
<a name="getrooms"></a>
|
||||||
|
Retreives a list of all rooms currently owned by the session token.
|
||||||
|
Each list item will contain a dict with the room's details:
|
||||||
|
- Room SUUID (`room_id`)
|
||||||
|
- Seed SUUID (`seed_id`)
|
||||||
|
- Creation timestamp (`creation_time`)
|
||||||
|
- Last activity timestamp (`last_activity`)
|
||||||
|
- Last known AP port (`last_port`)
|
||||||
|
- Room timeout counter in seconds (`timeout`)
|
||||||
|
- Room tracker SUUID (`tracker`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"creation_time": "Fri, 18 Apr 2025 19:46:53 GMT",
|
||||||
|
"last_activity": "Fri, 18 Apr 2025 21:16:02 GMT",
|
||||||
|
"last_port": 52122,
|
||||||
|
"room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a",
|
||||||
|
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6",
|
||||||
|
"timeout": 7200,
|
||||||
|
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"creation_time": "Fri, 18 Apr 2025 20:36:42 GMT",
|
||||||
|
"last_activity": "Fri, 18 Apr 2025 20:36:46 GMT",
|
||||||
|
"last_port": 56884,
|
||||||
|
"room_id": "14465c05-d08e-4d28-96bd-916f994609d8",
|
||||||
|
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb",
|
||||||
|
"timeout": 7200,
|
||||||
|
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/get_seeds`
|
||||||
|
<a name="getseeds"></a>
|
||||||
|
Retreives a list of all seeds currently owned by the session token.
|
||||||
|
Each item in the list will contain a dict with the seed's details:
|
||||||
|
- Seed SUUID (`seed_id`)
|
||||||
|
- Creation timestamp (`creation_time`)
|
||||||
|
- A list of player slots (`players`)
|
||||||
|
- Each item in the list will contain a list of the slot name and game
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
|
||||||
|
"players": [
|
||||||
|
[
|
||||||
|
"Slot_Name_1",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_2",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_3",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_4",
|
||||||
|
"Ocarina of Time"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_5",
|
||||||
|
"Ocarina of Time"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
|
||||||
|
"players": [
|
||||||
|
[
|
||||||
|
"Slot_Name_1",
|
||||||
|
"Clique"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_2",
|
||||||
|
"Clique"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_3",
|
||||||
|
"Clique"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Slot_Name_4",
|
||||||
|
"Archipelago"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -515,6 +515,7 @@ In addition, the following methods can be implemented and are called in this ord
|
|||||||
called per player before any items or locations are created. You can set properties on your
|
called per player before any items or locations are created. You can set properties on your
|
||||||
world here. Already has access to player options and RNG. This is the earliest step where the world should start
|
world here. Already has access to player options and RNG. This is the earliest step where the world should start
|
||||||
setting up for the current multiworld, as the multiworld itself is still setting up before this point.
|
setting up for the current multiworld, as the multiworld itself is still setting up before this point.
|
||||||
|
You cannot modify `local_items`, or `non_local_items` after this step.
|
||||||
* `create_regions(self)`
|
* `create_regions(self)`
|
||||||
called to place player's regions and their locations into the MultiWorld's regions list.
|
called to place player's regions and their locations into the MultiWorld's regions list.
|
||||||
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
|
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
|
||||||
@@ -538,7 +539,7 @@ In addition, the following methods can be implemented and are called in this ord
|
|||||||
creates the output files if there is output to be generated. When this is called,
|
creates the output files if there is output to be generated. When this is called,
|
||||||
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
|
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
|
||||||
item. `location.item.player` can be used to see if it's a local item.
|
item. `location.item.player` can be used to see if it's a local item.
|
||||||
* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
|
* `fill_slot_data(self)` and `modify_multidata(self, multidata: MultiData)` can be used to modify the data that
|
||||||
will be used by the server to host the MultiWorld.
|
will be used by the server to host the MultiWorld.
|
||||||
|
|
||||||
All instance methods can, optionally, have a class method defined which will be called after all instance methods are
|
All instance methods can, optionally, have a class method defined which will be called after all instance methods are
|
||||||
@@ -611,17 +612,10 @@ def create_items(self) -> None:
|
|||||||
# If there are two of the same item, the item has to be twice in the pool.
|
# If there are two of the same item, the item has to be twice in the pool.
|
||||||
# Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt.
|
# Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt.
|
||||||
# Having an item in the start inventory won't remove it from the pool.
|
# Having an item in the start inventory won't remove it from the pool.
|
||||||
# If an item can't have duplicates it has to be excluded manually.
|
# If you want to do that, use start_inventory_from_pool
|
||||||
|
|
||||||
# List of items to exclude, as a copy since it will be destroyed below
|
|
||||||
exclude = [item for item in self.multiworld.precollected_items[self.player]]
|
|
||||||
|
|
||||||
for item in map(self.create_item, mygame_items):
|
for item in map(self.create_item, mygame_items):
|
||||||
if item in exclude:
|
self.multiworld.itempool.append(item)
|
||||||
exclude.remove(item) # this is destructive. create unique list above
|
|
||||||
self.multiworld.itempool.append(self.create_item("nothing"))
|
|
||||||
else:
|
|
||||||
self.multiworld.itempool.append(item)
|
|
||||||
|
|
||||||
# itempool and number of locations should match up.
|
# itempool and number of locations should match up.
|
||||||
# If this is not the case we want to fill the itempool with junk.
|
# If this is not the case we want to fill the itempool with junk.
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ class EntranceLookup:
|
|||||||
_coupled: bool
|
_coupled: bool
|
||||||
_usable_exits: set[Entrance]
|
_usable_exits: set[Entrance]
|
||||||
|
|
||||||
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
|
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance], targets: Iterable[Entrance]):
|
||||||
self.dead_ends = EntranceLookup.GroupLookup()
|
self.dead_ends = EntranceLookup.GroupLookup()
|
||||||
self.others = EntranceLookup.GroupLookup()
|
self.others = EntranceLookup.GroupLookup()
|
||||||
self._random = rng
|
self._random = rng
|
||||||
self._expands_graph_cache = {}
|
self._expands_graph_cache = {}
|
||||||
self._coupled = coupled
|
self._coupled = coupled
|
||||||
self._usable_exits = usable_exits
|
self._usable_exits = usable_exits
|
||||||
|
for target in targets:
|
||||||
|
self.add(target)
|
||||||
|
|
||||||
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -121,7 +123,14 @@ class EntranceLookup:
|
|||||||
dead_end: bool,
|
dead_end: bool,
|
||||||
preserve_group_order: bool
|
preserve_group_order: bool
|
||||||
) -> Iterable[Entrance]:
|
) -> Iterable[Entrance]:
|
||||||
|
"""
|
||||||
|
Gets available targets for the requested groups
|
||||||
|
|
||||||
|
:param groups: The groups to find targets for
|
||||||
|
:param dead_end: Whether to find dead ends. If false, finds non-dead-ends
|
||||||
|
:param preserve_group_order: Whether to preserve the group order in the returned iterable. If true, a sequence
|
||||||
|
like AAABBB is guaranteed. If false, groups can be interleaved, e.g. BAABAB.
|
||||||
|
"""
|
||||||
lookup = self.dead_ends if dead_end else self.others
|
lookup = self.dead_ends if dead_end else self.others
|
||||||
if preserve_group_order:
|
if preserve_group_order:
|
||||||
for group in groups:
|
for group in groups:
|
||||||
@@ -132,6 +141,27 @@ class EntranceLookup:
|
|||||||
self._random.shuffle(ret)
|
self._random.shuffle(ret)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
def find_target(self, name: str, group: int | None = None, dead_end: bool | None = None) -> Entrance | None:
|
||||||
|
"""
|
||||||
|
Finds a specific target in the lookup, if it is present.
|
||||||
|
|
||||||
|
:param name: The name of the target
|
||||||
|
:param group: The target's group. Providing this will make the lookup faster, but can be omitted if it is not
|
||||||
|
known ahead of time for some reason.
|
||||||
|
:param dead_end: Whether the target is a dead end. Providing this will make the lookup faster, but can be
|
||||||
|
omitted if this is not known ahead of time (much more likely)
|
||||||
|
"""
|
||||||
|
if dead_end is None:
|
||||||
|
return (found
|
||||||
|
if (found := self.find_target(name, group, True))
|
||||||
|
else self.find_target(name, group, False))
|
||||||
|
lookup = self.dead_ends if dead_end else self.others
|
||||||
|
targets_to_check = lookup if group is None else lookup[group]
|
||||||
|
for target in targets_to_check:
|
||||||
|
if target.name == name:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
return len(self.dead_ends) + len(self.others)
|
return len(self.dead_ends) + len(self.others)
|
||||||
|
|
||||||
@@ -146,15 +176,18 @@ class ERPlacementState:
|
|||||||
"""The world which is having its entrances randomized"""
|
"""The world which is having its entrances randomized"""
|
||||||
collection_state: CollectionState
|
collection_state: CollectionState
|
||||||
"""The CollectionState backing the entrance randomization logic"""
|
"""The CollectionState backing the entrance randomization logic"""
|
||||||
|
entrance_lookup: EntranceLookup
|
||||||
|
"""A lookup table of all unconnected ER targets"""
|
||||||
coupled: bool
|
coupled: bool
|
||||||
"""Whether entrance randomization is operating in coupled mode"""
|
"""Whether entrance randomization is operating in coupled mode"""
|
||||||
|
|
||||||
def __init__(self, world: World, coupled: bool):
|
def __init__(self, world: World, entrance_lookup: EntranceLookup, coupled: bool):
|
||||||
self.placements = []
|
self.placements = []
|
||||||
self.pairings = []
|
self.pairings = []
|
||||||
self.world = world
|
self.world = world
|
||||||
self.coupled = coupled
|
self.coupled = coupled
|
||||||
self.collection_state = world.multiworld.get_all_state(False, True)
|
self.collection_state = world.multiworld.get_all_state(False, True)
|
||||||
|
self.entrance_lookup = entrance_lookup
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def placed_regions(self) -> set[Region]:
|
def placed_regions(self) -> set[Region]:
|
||||||
@@ -182,6 +215,7 @@ class ERPlacementState:
|
|||||||
self.collection_state.stale[self.world.player] = True
|
self.collection_state.stale[self.world.player] = True
|
||||||
self.placements.append(source_exit)
|
self.placements.append(source_exit)
|
||||||
self.pairings.append((source_exit.name, target_entrance.name))
|
self.pairings.append((source_exit.name, target_entrance.name))
|
||||||
|
self.entrance_lookup.remove(target_entrance)
|
||||||
|
|
||||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
||||||
usable_exits: set[Entrance]) -> bool:
|
usable_exits: set[Entrance]) -> bool:
|
||||||
@@ -311,7 +345,7 @@ def randomize_entrances(
|
|||||||
preserve_group_order: bool = False,
|
preserve_group_order: bool = False,
|
||||||
er_targets: list[Entrance] | None = None,
|
er_targets: list[Entrance] | None = None,
|
||||||
exits: list[Entrance] | None = None,
|
exits: list[Entrance] | None = None,
|
||||||
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
|
on_connect: Callable[[ERPlacementState, list[Entrance], list[Entrance]], bool | None] | None = None
|
||||||
) -> ERPlacementState:
|
) -> ERPlacementState:
|
||||||
"""
|
"""
|
||||||
Randomizes Entrances for a single world in the multiworld.
|
Randomizes Entrances for a single world in the multiworld.
|
||||||
@@ -328,14 +362,18 @@ def randomize_entrances(
|
|||||||
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
|
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
|
||||||
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
|
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
|
||||||
:param on_connect: A callback function which allows specifying side effects after a placement is completed
|
:param on_connect: A callback function which allows specifying side effects after a placement is completed
|
||||||
successfully and the underlying collection state has been updated.
|
successfully and the underlying collection state has been updated. The arguments are
|
||||||
|
1. The ER state
|
||||||
|
2. The exits placed in this placement pass
|
||||||
|
3. The entrances they were connected to.
|
||||||
|
If you use on_connect to make additional placements, you are expected to return True to inform
|
||||||
|
GER that an additional sweep is needed.
|
||||||
"""
|
"""
|
||||||
if not world.explicit_indirect_conditions:
|
if not world.explicit_indirect_conditions:
|
||||||
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
|
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
|
||||||
+ "to correctly analyze whether dead end regions can be required in logic.")
|
+ "to correctly analyze whether dead end regions can be required in logic.")
|
||||||
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
er_state = ERPlacementState(world, coupled)
|
|
||||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||||
perform_validity_check = True
|
perform_validity_check = True
|
||||||
|
|
||||||
@@ -351,23 +389,25 @@ def randomize_entrances(
|
|||||||
|
|
||||||
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
||||||
exits_set = set(exits)
|
exits_set = set(exits)
|
||||||
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
|
|
||||||
for entrance in er_targets:
|
|
||||||
entrance_lookup.add(entrance)
|
|
||||||
|
|
||||||
|
er_state = ERPlacementState(
|
||||||
|
world,
|
||||||
|
EntranceLookup(world.random, coupled, exits_set, er_targets),
|
||||||
|
coupled
|
||||||
|
)
|
||||||
# place the menu region and connected start region(s)
|
# place the menu region and connected start region(s)
|
||||||
er_state.collection_state.update_reachable_regions(world.player)
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
|
||||||
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
|
||||||
# remove the placed targets from consideration
|
|
||||||
for entrance in removed_entrances:
|
|
||||||
entrance_lookup.remove(entrance)
|
|
||||||
# propagate new connections
|
# propagate new connections
|
||||||
er_state.collection_state.update_reachable_regions(world.player)
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
er_state.collection_state.sweep_for_advancements()
|
er_state.collection_state.sweep_for_advancements()
|
||||||
if on_connect:
|
if on_connect:
|
||||||
on_connect(er_state, placed_exits)
|
change = on_connect(er_state, placed_exits, paired_entrances)
|
||||||
|
if change:
|
||||||
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
er_state.collection_state.sweep_for_advancements()
|
||||||
|
|
||||||
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
|
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
|
||||||
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
|
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
|
||||||
@@ -388,12 +428,12 @@ def randomize_entrances(
|
|||||||
# check to see if we are proposing the last placement
|
# check to see if we are proposing the last placement
|
||||||
if not coupled:
|
if not coupled:
|
||||||
# in uncoupled, this check is easy as there will only be one target.
|
# in uncoupled, this check is easy as there will only be one target.
|
||||||
is_last_placement = len(entrance_lookup) == 1
|
is_last_placement = len(er_state.entrance_lookup) == 1
|
||||||
else:
|
else:
|
||||||
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
|
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
|
||||||
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
|
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
|
||||||
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
|
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
|
||||||
is_last_placement = len(entrance_lookup) == desired_target_count
|
is_last_placement = len(er_state.entrance_lookup) == desired_target_count
|
||||||
# if it's not the last placement, we need a sweep
|
# if it's not the last placement, we need a sweep
|
||||||
return not is_last_placement
|
return not is_last_placement
|
||||||
|
|
||||||
@@ -402,7 +442,7 @@ def randomize_entrances(
|
|||||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
||||||
for source_exit in placeable_exits:
|
for source_exit in placeable_exits:
|
||||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||||
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
for target_entrance in er_state.entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||||
# when requiring new exits, ideally we would like to make it so that every placement increases
|
# when requiring new exits, ideally we would like to make it so that every placement increases
|
||||||
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
|
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
|
||||||
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
|
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
|
||||||
@@ -420,7 +460,7 @@ def randomize_entrances(
|
|||||||
else:
|
else:
|
||||||
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
|
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
|
||||||
# deadlocking is a frequent issue.
|
# deadlocking is a frequent issue.
|
||||||
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
|
lookup = er_state.entrance_lookup.dead_ends if dead_end else er_state.entrance_lookup.others
|
||||||
|
|
||||||
# if we're in a stage where we're trying to get to new regions, we could also enter this
|
# if we're in a stage where we're trying to get to new regions, we could also enter this
|
||||||
# branch in a success state (when all regions of the preferred type have been placed, but there are still
|
# branch in a success state (when all regions of the preferred type have been placed, but there are still
|
||||||
@@ -466,21 +506,21 @@ def randomize_entrances(
|
|||||||
f"All unplaced exits: {unplaced_exits}")
|
f"All unplaced exits: {unplaced_exits}")
|
||||||
|
|
||||||
# stage 1 - try to place all the non-dead-end entrances
|
# stage 1 - try to place all the non-dead-end entrances
|
||||||
while entrance_lookup.others:
|
while er_state.entrance_lookup.others:
|
||||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||||
break
|
break
|
||||||
# stage 2 - try to place all the dead-end entrances
|
# stage 2 - try to place all the dead-end entrances
|
||||||
while entrance_lookup.dead_ends:
|
while er_state.entrance_lookup.dead_ends:
|
||||||
if not find_pairing(dead_end=True, require_new_exits=True):
|
if not find_pairing(dead_end=True, require_new_exits=True):
|
||||||
break
|
break
|
||||||
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
|
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
|
||||||
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
|
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
|
||||||
# doing this before the non-dead-ends is important to ensure there are enough connections to
|
# doing this before the non-dead-ends is important to ensure there are enough connections to
|
||||||
# go around
|
# go around
|
||||||
while entrance_lookup.dead_ends:
|
while er_state.entrance_lookup.dead_ends:
|
||||||
find_pairing(dead_end=True, require_new_exits=False)
|
find_pairing(dead_end=True, require_new_exits=False)
|
||||||
# stage 3b - tie all the other loose ends connecting visited regions to each other
|
# stage 3b - tie all the other loose ends connecting visited regions to each other
|
||||||
while entrance_lookup.others:
|
while er_state.entrance_lookup.others:
|
||||||
find_pairing(dead_end=False, require_new_exits=False)
|
find_pairing(dead_end=False, require_new_exits=False)
|
||||||
|
|
||||||
running_time = time.perf_counter() - start_time
|
running_time = time.perf_counter() - start_time
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ Name: "full"; Description: "Full installation"
|
|||||||
Name: "minimal"; Description: "Minimal installation"
|
Name: "minimal"; Description: "Minimal installation"
|
||||||
Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
||||||
|
|
||||||
[Components]
|
|
||||||
Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed
|
|
||||||
Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full;
|
|
||||||
|
|
||||||
[Dirs]
|
[Dirs]
|
||||||
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
|
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
|
||||||
|
|
||||||
@@ -76,7 +72,6 @@ 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}\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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
|
python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported
|
||||||
python_classes = Test
|
python_classes = Test
|
||||||
python_functions = test
|
python_functions = test
|
||||||
testpaths =
|
testpaths =
|
||||||
|
|||||||
46
setup.py
46
setup.py
@@ -9,6 +9,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import sysconfig
|
import sysconfig
|
||||||
import threading
|
import threading
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -16,6 +17,10 @@ from collections.abc import Iterable, Sequence
|
|||||||
from hashlib import sha3_512
|
from hashlib import sha3_512
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fixed
|
||||||
|
|
||||||
|
|
||||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
requirement = 'cx-Freeze==8.0.0'
|
requirement = 'cx-Freeze==8.0.0'
|
||||||
try:
|
try:
|
||||||
@@ -57,13 +62,11 @@ from Utils import version_tuple, is_windows, is_linux
|
|||||||
from Cython.Build import cythonize
|
from Cython.Build import cythonize
|
||||||
|
|
||||||
|
|
||||||
# On Python < 3.10 LogicMixin is not currently supported.
|
|
||||||
non_apworlds: set[str] = {
|
non_apworlds: set[str] = {
|
||||||
"A Link to the Past",
|
"A Link to the Past",
|
||||||
"Adventure",
|
"Adventure",
|
||||||
"ArchipIDLE",
|
"ArchipIDLE",
|
||||||
"Archipelago",
|
"Archipelago",
|
||||||
"Clique",
|
|
||||||
"Lufia II Ancient Cave",
|
"Lufia II Ancient Cave",
|
||||||
"Meritous",
|
"Meritous",
|
||||||
"Ocarina of Time",
|
"Ocarina of Time",
|
||||||
@@ -75,9 +78,6 @@ non_apworlds: set[str] = {
|
|||||||
"Wargroove",
|
"Wargroove",
|
||||||
}
|
}
|
||||||
|
|
||||||
# LogicMixin is broken before 3.10 import revamp
|
|
||||||
if sys.version_info < (3,10):
|
|
||||||
non_apworlds.add("Hollow Knight")
|
|
||||||
|
|
||||||
def download_SNI() -> None:
|
def download_SNI() -> None:
|
||||||
print("Updating SNI")
|
print("Updating SNI")
|
||||||
@@ -90,7 +90,8 @@ def download_SNI() -> None:
|
|||||||
machine_name = platform.machine().lower()
|
machine_name = platform.machine().lower()
|
||||||
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
|
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
|
||||||
machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
|
machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
|
||||||
with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
|
sni_version_ref = "latest" if SNI_VERSION == "latest" else f"tags/{SNI_VERSION}"
|
||||||
|
with urllib.request.urlopen(f"https://api.github.com/repos/alttpo/SNI/releases/{sni_version_ref}") as request:
|
||||||
data = json.load(request)
|
data = json.load(request)
|
||||||
files = data["assets"]
|
files = data["assets"]
|
||||||
|
|
||||||
@@ -104,8 +105,8 @@ def download_SNI() -> None:
|
|||||||
# prefer "many" builds
|
# prefer "many" builds
|
||||||
if "many" in download_url:
|
if "many" in download_url:
|
||||||
break
|
break
|
||||||
# prefer the correct windows or windows7 build
|
# prefer non-windows7 builds to get up-to-date dependencies
|
||||||
if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)):
|
if platform_name == "windows" and "windows7" not in download_url:
|
||||||
break
|
break
|
||||||
|
|
||||||
if source_url and source_url.endswith(".zip"):
|
if source_url and source_url.endswith(".zip"):
|
||||||
@@ -144,15 +145,16 @@ def download_SNI() -> None:
|
|||||||
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
||||||
|
|
||||||
|
|
||||||
signtool: str | None
|
signtool: str | None = None
|
||||||
if os.path.exists("X:/pw.txt"):
|
try:
|
||||||
print("Using signtool")
|
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
|
||||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
html = response.read()
|
||||||
pw = f.read()
|
if b"status=OK\n" in html:
|
||||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||||
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
|
r'/tr http://timestamp.digicert.com/ ')
|
||||||
else:
|
print("Using signtool")
|
||||||
signtool = None
|
except (ConnectionError, TimeoutError, urllib.error.URLError) as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
build_platform = sysconfig.get_platform()
|
build_platform = sysconfig.get_platform()
|
||||||
@@ -197,9 +199,10 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
|||||||
|
|
||||||
|
|
||||||
def remove_sprites_from_folder(folder: Path) -> None:
|
def remove_sprites_from_folder(folder: Path) -> None:
|
||||||
for file in os.listdir(folder):
|
if os.path.isdir(folder):
|
||||||
if file != ".gitignore":
|
for file in os.listdir(folder):
|
||||||
os.remove(folder / file)
|
if file != ".gitignore":
|
||||||
|
os.remove(folder / file)
|
||||||
|
|
||||||
|
|
||||||
def _threaded_hash(filepath: str | Path) -> str:
|
def _threaded_hash(filepath: str | Path) -> str:
|
||||||
@@ -408,13 +411,14 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
|
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
|
||||||
|
|
||||||
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr")
|
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr")
|
||||||
|
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote")
|
||||||
|
|
||||||
self.create_manifest()
|
self.create_manifest()
|
||||||
|
|
||||||
if is_windows:
|
if is_windows:
|
||||||
# Inno setup stuff
|
# Inno setup stuff
|
||||||
with open("setup.ini", "w") as f:
|
with open("setup.ini", "w") as f:
|
||||||
min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
|
min_supported_windows = "6.2.9200"
|
||||||
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
|
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
|
||||||
with open("installdelete.iss", "w") as f:
|
with open("installdelete.iss", "w") as f:
|
||||||
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
|
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
|
||||||
|
|||||||
@@ -29,14 +29,9 @@ def run_locations_benchmark():
|
|||||||
|
|
||||||
rule_iterations: int = 100_000
|
rule_iterations: int = 100_000
|
||||||
|
|
||||||
if sys.version_info >= (3, 9):
|
@staticmethod
|
||||||
@staticmethod
|
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
|
||||||
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
|
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
|
||||||
else:
|
|
||||||
@staticmethod
|
|
||||||
def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str:
|
|
||||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
|
||||||
|
|
||||||
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
||||||
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
||||||
|
|||||||
66
test/benchmark/match.py
Normal file
66
test/benchmark/match.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Micro benchmark comparing match as "switch" with if-elif and dict access"""
|
||||||
|
|
||||||
|
from timeit import timeit
|
||||||
|
|
||||||
|
|
||||||
|
def make_match(count: int) -> str:
|
||||||
|
code = f"for val in range({count}):\n match val:\n"
|
||||||
|
for n in range(count):
|
||||||
|
m = n + 1
|
||||||
|
code += f" case {n}:\n"
|
||||||
|
code += f" res = {m}\n"
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def make_elif(count: int) -> str:
|
||||||
|
code = f"for val in range({count}):\n"
|
||||||
|
for n in range(count):
|
||||||
|
m = n + 1
|
||||||
|
code += f" {'' if n == 0 else 'el'}if val == {n}:\n"
|
||||||
|
code += f" res = {m}\n"
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def make_dict(count: int, mode: str) -> str:
|
||||||
|
if mode == "value":
|
||||||
|
code = "dct = {\n"
|
||||||
|
for n in range(count):
|
||||||
|
m = n + 1
|
||||||
|
code += f" {n}: {m},\n"
|
||||||
|
code += "}\n"
|
||||||
|
code += f"for val in range({count}):\n res = dct[val]"
|
||||||
|
return code
|
||||||
|
elif mode == "call":
|
||||||
|
code = ""
|
||||||
|
for n in range(count):
|
||||||
|
m = n + 1
|
||||||
|
code += f"def func{n}():\n val = {m}\n\n"
|
||||||
|
code += "dct = {\n"
|
||||||
|
for n in range(count):
|
||||||
|
code += f" {n}: func{n},\n"
|
||||||
|
code += "}\n"
|
||||||
|
code += f"for val in range({count}):\n dct[val]()"
|
||||||
|
return code
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def timeit_best_of_5(stmt: str, setup: str = "pass") -> float:
|
||||||
|
"""
|
||||||
|
Benchmark some code, returning the best of 5 runs.
|
||||||
|
:param stmt: Code to benchmark
|
||||||
|
:param setup: Optional code to set up environment
|
||||||
|
:return: Time taken in microseconds
|
||||||
|
"""
|
||||||
|
return min(timeit(stmt, setup, number=10000, globals={}) for _ in range(5)) * 100
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
for count in (3, 5, 8, 10, 20, 30):
|
||||||
|
print(f"value of {count:-2} with match: {timeit_best_of_5(make_match(count)) / count:.3f} us")
|
||||||
|
print(f"value of {count:-2} with elif: {timeit_best_of_5(make_elif(count)) / count:.3f} us")
|
||||||
|
print(f"value of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'value')) / count:.3f} us")
|
||||||
|
print(f"call of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'call')) / count:.3f} us")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -69,11 +69,9 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
exits_set = set([ex for region in multiworld.get_regions(1)
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
for ex in region.exits if not ex.connected_region])
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
|
||||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
for entrance in region.entrances if not entrance.parent_region]
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
for entrance in er_targets:
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
lookup.add(entrance)
|
|
||||||
|
|
||||||
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
||||||
False, False)
|
False, False)
|
||||||
@@ -92,11 +90,9 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
exits_set = set([ex for region in multiworld.get_regions(1)
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
for ex in region.exits if not ex.connected_region])
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
|
||||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
for entrance in region.entrances if not entrance.parent_region]
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
for entrance in er_targets:
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
lookup.add(entrance)
|
|
||||||
|
|
||||||
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
|
||||||
False, True)
|
False, True)
|
||||||
@@ -112,12 +108,10 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
for ex in region.exits if not ex.connected_region
|
for ex in region.exits if not ex.connected_region
|
||||||
and ex.name != "region20_right" and ex.name != "region21_left"])
|
and ex.name != "region20_right" and ex.name != "region21_left"])
|
||||||
|
|
||||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
|
||||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
for entrance in region.entrances if not entrance.parent_region and
|
for entrance in region.entrances if not entrance.parent_region and
|
||||||
entrance.name != "region20_right" and entrance.name != "region21_left"]
|
entrance.name != "region20_right" and entrance.name != "region21_left"]
|
||||||
for entrance in er_targets:
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
lookup.add(entrance)
|
|
||||||
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
|
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
|
||||||
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
|
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
|
||||||
# the top entrance from region 15 should be considered a dead-end
|
# the top entrance from region 15 should be considered a dead-end
|
||||||
@@ -129,6 +123,56 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
self.assertTrue(dead_end in lookup.dead_ends)
|
self.assertTrue(dead_end in lookup.dead_ends)
|
||||||
self.assertEqual(len(lookup.dead_ends), 1)
|
self.assertEqual(len(lookup.dead_ends), 1)
|
||||||
|
|
||||||
|
def test_find_target_by_name(self):
|
||||||
|
"""Tests that find_target can find the correct target by name only"""
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
|
|
||||||
|
target = lookup.find_target("region0_right")
|
||||||
|
self.assertEqual(target.name, "region0_right")
|
||||||
|
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
|
||||||
|
self.assertIsNone(lookup.find_target("nonexistant"))
|
||||||
|
|
||||||
|
def test_find_target_by_name_and_group(self):
|
||||||
|
"""Tests that find_target can find the correct target by name and group"""
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
|
|
||||||
|
target = lookup.find_target("region0_right", ERTestGroups.RIGHT)
|
||||||
|
self.assertEqual(target.name, "region0_right")
|
||||||
|
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
|
||||||
|
# wrong group
|
||||||
|
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.LEFT))
|
||||||
|
|
||||||
|
def test_find_target_by_name_and_group_and_category(self):
|
||||||
|
"""Tests that find_target can find the correct target by name, group, and dead-endedness"""
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
|
||||||
|
|
||||||
|
target = lookup.find_target("region0_right", ERTestGroups.RIGHT, False)
|
||||||
|
self.assertEqual(target.name, "region0_right")
|
||||||
|
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
|
||||||
|
# wrong deadendedness
|
||||||
|
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.RIGHT, True))
|
||||||
|
|
||||||
class TestBakeTargetGroupLookup(unittest.TestCase):
|
class TestBakeTargetGroupLookup(unittest.TestCase):
|
||||||
def test_lookup_generation(self):
|
def test_lookup_generation(self):
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
@@ -265,12 +309,12 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
generate_disconnected_region_grid(multiworld, 5)
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
seen_placement_count = 0
|
seen_placement_count = 0
|
||||||
|
|
||||||
def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
|
def verify_coupled(_: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
|
||||||
nonlocal seen_placement_count
|
nonlocal seen_placement_count
|
||||||
seen_placement_count += len(placed_entrances)
|
seen_placement_count += len(placed_exits)
|
||||||
self.assertEqual(2, len(placed_entrances))
|
self.assertEqual(2, len(placed_exits))
|
||||||
self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
|
self.assertEqual(placed_exits[0].parent_region, placed_exits[1].connected_region)
|
||||||
self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
|
self.assertEqual(placed_exits[1].parent_region, placed_exits[0].connected_region)
|
||||||
|
|
||||||
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
|
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
|
||||||
on_connect=verify_coupled)
|
on_connect=verify_coupled)
|
||||||
@@ -313,10 +357,10 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
generate_disconnected_region_grid(multiworld, 5)
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
seen_placement_count = 0
|
seen_placement_count = 0
|
||||||
|
|
||||||
def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
|
def verify_uncoupled(state: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
|
||||||
nonlocal seen_placement_count
|
nonlocal seen_placement_count
|
||||||
seen_placement_count += len(placed_entrances)
|
seen_placement_count += len(placed_exits)
|
||||||
self.assertEqual(1, len(placed_entrances))
|
self.assertEqual(1, len(placed_exits))
|
||||||
|
|
||||||
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
|
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
|
||||||
on_connect=verify_uncoupled)
|
on_connect=verify_uncoupled)
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ class TestBase(unittest.TestCase):
|
|||||||
|
|
||||||
original_get_all_state = multiworld.get_all_state
|
original_get_all_state = multiworld.get_all_state
|
||||||
|
|
||||||
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||||
|
**kwargs):
|
||||||
self.assertTrue(allow_partial_entrances, (
|
self.assertTrue(allow_partial_entrances, (
|
||||||
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||||
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||||
))
|
))
|
||||||
|
|
||||||
return original_get_all_state(use_cache, allow_partial_entrances)
|
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs)
|
||||||
|
|
||||||
multiworld.get_all_state = patched_get_all_state
|
multiworld.get_all_state = patched_get_all_state
|
||||||
|
|
||||||
|
|||||||
@@ -603,6 +603,28 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
|||||||
self.assertTrue(player3.locations[2].item.advancement)
|
self.assertTrue(player3.locations[2].item.advancement)
|
||||||
self.assertTrue(player3.locations[3].item.advancement)
|
self.assertTrue(player3.locations[3].item.advancement)
|
||||||
|
|
||||||
|
def test_deprioritized_does_not_land_on_priority(self):
|
||||||
|
multiworld = generate_test_multiworld(1)
|
||||||
|
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=2)
|
||||||
|
|
||||||
|
player1.prog_items[0].classification |= ItemClassification.deprioritized
|
||||||
|
player1.locations[0].progress_type = LocationProgressType.PRIORITY
|
||||||
|
|
||||||
|
distribute_items_restrictive(multiworld)
|
||||||
|
|
||||||
|
self.assertFalse(player1.locations[0].item.deprioritized)
|
||||||
|
|
||||||
|
def test_deprioritized_still_goes_on_priority_ahead_of_filler(self):
|
||||||
|
multiworld = generate_test_multiworld(1)
|
||||||
|
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=1, basic_item_count=1)
|
||||||
|
|
||||||
|
player1.prog_items[0].classification |= ItemClassification.deprioritized
|
||||||
|
player1.locations[0].progress_type = LocationProgressType.PRIORITY
|
||||||
|
|
||||||
|
distribute_items_restrictive(multiworld)
|
||||||
|
|
||||||
|
self.assertTrue(player1.locations[0].item.advancement)
|
||||||
|
|
||||||
def test_can_remove_locations_in_fill_hook(self):
|
def test_can_remove_locations_in_fill_hook(self):
|
||||||
"""Test that distribute_items_restrictive calls the fill hook and allows for item and location removal"""
|
"""Test that distribute_items_restrictive calls the fill hook and allows for item and location removal"""
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from Fill import distribute_items_restrictive
|
from Fill import distribute_items_restrictive
|
||||||
from NetUtils import encode
|
from NetUtils import convert_to_base_types
|
||||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||||
from worlds import failed_world_loads
|
from worlds import failed_world_loads
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld
|
||||||
@@ -47,7 +47,7 @@ class TestImplemented(unittest.TestCase):
|
|||||||
call_all(multiworld, "post_fill")
|
call_all(multiworld, "post_fill")
|
||||||
for key, data in multiworld.worlds[1].fill_slot_data().items():
|
for key, data in multiworld.worlds[1].fill_slot_data().items():
|
||||||
self.assertIsInstance(key, str, "keys in slot data must be a string")
|
self.assertIsInstance(key, str, "keys in slot data must be a string")
|
||||||
self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
|
convert_to_base_types(data) # only put base data types into slot data
|
||||||
|
|
||||||
def test_no_failed_world_loads(self):
|
def test_no_failed_world_loads(self):
|
||||||
if failed_world_loads:
|
if failed_world_loads:
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ class TestBase(unittest.TestCase):
|
|||||||
|
|
||||||
def test_locality_not_modified(self):
|
def test_locality_not_modified(self):
|
||||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
gen_steps = ("generate_early",)
|
||||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||||
for game_name, world_type in worlds_to_test.items():
|
for game_name, world_type in worlds_to_test.items():
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from Options import ItemLinks
|
from Options import ItemLinks, Choice
|
||||||
|
from Utils import restricted_dumps
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
|
|
||||||
@@ -73,9 +74,10 @@ class TestOptions(unittest.TestCase):
|
|||||||
|
|
||||||
def test_pickle_dumps(self):
|
def test_pickle_dumps(self):
|
||||||
"""Test options can be pickled into database for WebHost generation"""
|
"""Test options can be pickled into database for WebHost generation"""
|
||||||
import pickle
|
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden:
|
if not world_type.hidden:
|
||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
with self.subTest(game=gamename, option=option_key):
|
with self.subTest(game=gamename, option=option_key):
|
||||||
pickle.dumps(option.from_any(option.default))
|
restricted_dumps(option.from_any(option.default))
|
||||||
|
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||||
|
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ class TestPackages(unittest.TestCase):
|
|||||||
to indicate full package rather than namespace package."""
|
to indicate full package rather than namespace package."""
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
|
# Ignore directories with these names.
|
||||||
|
ignore_dirs = {".github"}
|
||||||
|
|
||||||
worlds_path = Utils.local_path("worlds")
|
worlds_path = Utils.local_path("worlds")
|
||||||
for dirpath, dirnames, filenames in os.walk(worlds_path):
|
for dirpath, dirnames, filenames in os.walk(worlds_path):
|
||||||
|
# Drop ignored directories from dirnames, excluding them from walking.
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
|
||||||
with self.subTest(directory=dirpath):
|
with self.subTest(directory=dirpath):
|
||||||
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))
|
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ if __name__ == "__main__":
|
|||||||
spacer = '=' * 80
|
spacer = '=' * 80
|
||||||
|
|
||||||
with TemporaryDirectory() as tempdir:
|
with TemporaryDirectory() as tempdir:
|
||||||
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
|
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
||||||
p1_games = []
|
p1_games = []
|
||||||
data_paths = []
|
data_paths = []
|
||||||
rooms = []
|
rooms = []
|
||||||
|
|
||||||
copy_world("Clique", "Temp World")
|
copy_world("VVVVVV", "Temp World")
|
||||||
try:
|
try:
|
||||||
for n, games in enumerate(multis, 1):
|
for n, games in enumerate(multis, 1):
|
||||||
print(f"Generating [{n}] {', '.join(games)}")
|
print(f"Generating [{n}] {', '.join(games)}")
|
||||||
@@ -101,7 +101,7 @@ if __name__ == "__main__":
|
|||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
local_data_packages = client.games_packages
|
local_data_packages = client.games_packages
|
||||||
local_collected_items = len(client.checked_locations)
|
local_collected_items = len(client.checked_locations)
|
||||||
if collected_items < 2: # Clique only has 2 Locations
|
if collected_items < 2: # Don't collect anything on the last iteration
|
||||||
client.collect_any()
|
client.collect_any()
|
||||||
# TODO: Ctrl+C test here as well
|
# TODO: Ctrl+C test here as well
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ if __name__ == "__main__":
|
|||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
web_data_packages = client.games_packages
|
web_data_packages = client.games_packages
|
||||||
web_collected_items = len(client.checked_locations)
|
web_collected_items = len(client.checked_locations)
|
||||||
if collected_items < 2: # Clique only has 2 Locations
|
if collected_items < 2: # Don't collect anything on the last iteration
|
||||||
client.collect_any()
|
client.collect_any()
|
||||||
if collected_items == 1:
|
if collected_items == 1:
|
||||||
sleep(1) # wait for the server to collect the item
|
sleep(1) # wait for the server to collect the item
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def _generate_local_inner(games: Iterable[str],
|
|||||||
f.write(json.dumps({
|
f.write(json.dumps({
|
||||||
"name": f"Player{n}",
|
"name": f"Player{n}",
|
||||||
"game": game,
|
"game": game,
|
||||||
game: {"hard_mode": "true"},
|
game: {},
|
||||||
"description": f"generate_local slot {n} ('Player{n}'): {game}",
|
"description": f"generate_local slot {n} ('Player{n}'): {game}",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
|
|
||||||
|
from WebHostLib import to_python
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from werkzeug.test import Client as FlaskClient
|
from werkzeug.test import Client as FlaskClient
|
||||||
@@ -103,7 +105,7 @@ def stop_room(app_client: "FlaskClient",
|
|||||||
poll_interval = 2
|
poll_interval = 2
|
||||||
|
|
||||||
print(f"Stopping room {room_id}")
|
print(f"Stopping room {room_id}")
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
room_uuid = to_python(room_id)
|
||||||
|
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
sleep(.1) # should not be required, but other things might use threading
|
sleep(.1) # should not be required, but other things might use threading
|
||||||
@@ -156,7 +158,7 @@ def set_room_timeout(room_id: str, timeout: float) -> None:
|
|||||||
from WebHostLib.models import Room
|
from WebHostLib.models import Room
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
room_uuid = to_python(room_id)
|
||||||
with db_session:
|
with db_session:
|
||||||
room: Room = Room.get(id=room_uuid)
|
room: Room = Room.get(id=room_uuid)
|
||||||
room.timeout = timeout
|
room.timeout = timeout
|
||||||
@@ -168,7 +170,7 @@ def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes
|
|||||||
from WebHostLib.models import Room
|
from WebHostLib.models import Room
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
room_uuid = to_python(room_id)
|
||||||
with db_session:
|
with db_session:
|
||||||
room: Room = Room.get(id=room_uuid)
|
room: Room = Room.get(id=room_uuid)
|
||||||
return cast(bytes, room.seed.multidata)
|
return cast(bytes, room.seed.multidata)
|
||||||
@@ -180,7 +182,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
|
|||||||
from WebHostLib.models import Room
|
from WebHostLib.models import Room
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
|
||||||
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
|
room_uuid = to_python(room_id)
|
||||||
with db_session:
|
with db_session:
|
||||||
room: Room = Room.get(id=room_uuid)
|
room: Room = Room.get(id=room_uuid)
|
||||||
room.seed.multidata = data
|
room.seed.multidata = data
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def copy(src: str, dst: str) -> None:
|
|||||||
_new_worlds[dst] = str(dst_folder)
|
_new_worlds[dst] = str(dst_folder)
|
||||||
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
||||||
contents = f.read()
|
contents = f.read()
|
||||||
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
||||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
||||||
f.write(contents)
|
f.write(contents)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ class TestNumericOptions(unittest.TestCase):
|
|||||||
self.assertEqual(choice_option_alias, TestChoice.alias_three)
|
self.assertEqual(choice_option_alias, TestChoice.alias_three)
|
||||||
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
|
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
|
||||||
|
|
||||||
|
self.assertLess(choice_option_string, "two")
|
||||||
|
self.assertGreater(choice_option_string, "zero")
|
||||||
|
self.assertLessEqual(choice_option_string, "one")
|
||||||
|
self.assertLessEqual(choice_option_string, "two")
|
||||||
|
self.assertGreaterEqual(choice_option_string, "one")
|
||||||
|
self.assertGreaterEqual(choice_option_string, "zero")
|
||||||
|
|
||||||
|
self.assertGreaterEqual(choice_option_alias, "three")
|
||||||
|
|
||||||
self.assertRaises(KeyError, TestChoice.from_any, "four")
|
self.assertRaises(KeyError, TestChoice.from_any, "four")
|
||||||
|
|
||||||
self.assertIn(choice_option_int, [1, 2, 3])
|
self.assertIn(choice_option_int, [1, 2, 3])
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import unittest
|
|||||||
import Utils
|
import Utils
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
import WebHost
|
import WebHost
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
@@ -9,36 +11,30 @@ from worlds.AutoWorld import AutoWorldRegister
|
|||||||
class TestDocs(unittest.TestCase):
|
class TestDocs(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
cls.tutorials_data = WebHost.create_ordered_tutorials_file()
|
WebHost.copy_tutorials_files_to_static()
|
||||||
|
|
||||||
def test_has_tutorial(self):
|
def test_has_tutorial(self):
|
||||||
games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data)
|
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden:
|
if not world_type.hidden:
|
||||||
with self.subTest(game_name):
|
with self.subTest(game_name):
|
||||||
try:
|
tutorials = world_type.web.tutorials
|
||||||
self.assertIn(game_name, games_with_tutorial)
|
self.assertGreater(len(tutorials), 0, msg=f"{game_name} has no setup tutorial.")
|
||||||
except AssertionError:
|
|
||||||
# look for partial name in the tutorial name
|
safe_name = secure_filename(game_name)
|
||||||
for game in games_with_tutorial:
|
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
||||||
if game_name in game:
|
for tutorial in tutorials:
|
||||||
break
|
self.assertTrue(
|
||||||
else:
|
os.path.isfile(Utils.local_path(target_path, secure_filename(tutorial.file_name))),
|
||||||
self.fail(f"{game_name} has no setup tutorial. "
|
f'{game_name} missing tutorial file {tutorial.file_name}.'
|
||||||
f"Games with Tutorial: {games_with_tutorial}")
|
)
|
||||||
|
|
||||||
def test_has_game_info(self):
|
def test_has_game_info(self):
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden:
|
if not world_type.hidden:
|
||||||
safe_name = Utils.get_file_safe_name(game_name)
|
safe_name = secure_filename(game_name)
|
||||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
||||||
for game_info_lang in world_type.web.game_info_languages:
|
for game_info_lang in world_type.web.game_info_languages:
|
||||||
with self.subTest(game_name):
|
with self.subTest(game_name):
|
||||||
self.assertTrue(
|
|
||||||
safe_name == game_name or
|
|
||||||
not os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{game_name}.md')),
|
|
||||||
f'Info docs have be named <lang>_{safe_name}.md for {game_name}.'
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{safe_name}.md')),
|
os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{safe_name}.md')),
|
||||||
f'{game_name} missing game info file for "{game_info_lang}" language.'
|
f'{game_name} missing game info file for "{game_info_lang}" language.'
|
||||||
|
|||||||
@@ -29,8 +29,3 @@ class TestFileGeneration(unittest.TestCase):
|
|||||||
with open(file, encoding="utf-8-sig") as f:
|
with open(file, encoding="utf-8-sig") as f:
|
||||||
for value in roll_options({file.name: f.read()})[0].values():
|
for value in roll_options({file.name: f.read()})[0].values():
|
||||||
self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.")
|
self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.")
|
||||||
|
|
||||||
def test_tutorial(self):
|
|
||||||
WebHost.create_ordered_tutorials_file()
|
|
||||||
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json")))
|
|
||||||
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json")))
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from Utils import deprecate
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||||
from . import GamesPackage
|
from NetUtils import GamesPackage, MultiData
|
||||||
from settings import Group
|
from settings import Group
|
||||||
|
|
||||||
perf_logger = logging.getLogger("performance")
|
perf_logger = logging.getLogger("performance")
|
||||||
@@ -72,15 +72,6 @@ class AutoWorldRegister(type):
|
|||||||
dct["required_client_version"] = max(dct["required_client_version"],
|
dct["required_client_version"] = max(dct["required_client_version"],
|
||||||
base.__dict__["required_client_version"])
|
base.__dict__["required_client_version"])
|
||||||
|
|
||||||
# create missing options_dataclass from legacy option_definitions
|
|
||||||
# TODO - remove this once all worlds use options dataclasses
|
|
||||||
if "options_dataclass" not in dct and "option_definitions" in dct:
|
|
||||||
# TODO - switch to deprecate after a version
|
|
||||||
deprecate(f"{name} Assigned options through option_definitions which is now deprecated. "
|
|
||||||
"Please use options_dataclass instead.")
|
|
||||||
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
|
|
||||||
bases=(PerGameCommonOptions,))
|
|
||||||
|
|
||||||
# construct class
|
# construct class
|
||||||
new_class = super().__new__(mcs, name, bases, dct)
|
new_class = super().__new__(mcs, name, bases, dct)
|
||||||
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
||||||
@@ -450,7 +441,7 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
|
def modify_multidata(self, multidata: "MultiData") -> None:
|
||||||
"""For deeper modification of server multidata."""
|
"""For deeper modification of server multidata."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -493,9 +484,6 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
Creates a group, which is an instance of World that is responsible for multiple others.
|
Creates a group, which is an instance of World that is responsible for multiple others.
|
||||||
An example case is ItemLinks creating these.
|
An example case is ItemLinks creating these.
|
||||||
"""
|
"""
|
||||||
# TODO remove loop when worlds use options dataclass
|
|
||||||
for option_key, option in cls.options_dataclass.type_hints.items():
|
|
||||||
getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default)
|
|
||||||
group = cls(multiworld, new_player_id)
|
group = cls(multiworld, new_player_id)
|
||||||
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
|
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
|
||||||
for option_key, option in cls.options_dataclass.type_hints.items()})
|
for option_key, option in cls.options_dataclass.type_hints.items()})
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import bsdiff4
|
|||||||
semaphore = threading.Semaphore(os.cpu_count() or 4)
|
semaphore = threading.Semaphore(os.cpu_count() or 4)
|
||||||
|
|
||||||
del threading
|
del threading
|
||||||
del os
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(abc.ABCMeta):
|
class AutoPatchRegister(abc.ABCMeta):
|
||||||
@@ -34,10 +33,8 @@ class AutoPatchRegister(abc.ABCMeta):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_handler(file: str) -> Optional[AutoPatchRegister]:
|
def get_handler(file: str) -> Optional[AutoPatchRegister]:
|
||||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
_, suffix = os.path.splitext(file)
|
||||||
if file.endswith(file_ending):
|
return AutoPatchRegister.file_endings.get(suffix, None)
|
||||||
return handler
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchExtensionRegister(abc.ABCMeta):
|
class AutoPatchExtensionRegister(abc.ABCMeta):
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import warnings
|
|||||||
import zipimport
|
import zipimport
|
||||||
import time
|
import time
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from typing import Dict, List, TypedDict
|
from typing import List
|
||||||
|
|
||||||
|
from NetUtils import DataPackage
|
||||||
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__)
|
||||||
@@ -24,8 +25,6 @@ __all__ = {
|
|||||||
"world_sources",
|
"world_sources",
|
||||||
"local_folder",
|
"local_folder",
|
||||||
"user_folder",
|
"user_folder",
|
||||||
"GamesPackage",
|
|
||||||
"DataPackage",
|
|
||||||
"failed_world_loads",
|
"failed_world_loads",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,18 +32,6 @@ __all__ = {
|
|||||||
failed_world_loads: List[str] = []
|
failed_world_loads: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
class GamesPackage(TypedDict, total=False):
|
|
||||||
item_name_groups: Dict[str, List[str]]
|
|
||||||
item_name_to_id: Dict[str, int]
|
|
||||||
location_name_groups: Dict[str, List[str]]
|
|
||||||
location_name_to_id: Dict[str, int]
|
|
||||||
checksum: str
|
|
||||||
|
|
||||||
|
|
||||||
class DataPackage(TypedDict):
|
|
||||||
games: Dict[str, GamesPackage]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(order=True)
|
@dataclasses.dataclass(order=True)
|
||||||
class WorldSource:
|
class WorldSource:
|
||||||
path: str # typically relative path from this module
|
path: str # typically relative path from this module
|
||||||
@@ -76,9 +63,7 @@ class WorldSource:
|
|||||||
sys.modules[mod.__name__] = mod
|
sys.modules[mod.__name__] = mod
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
||||||
# Found no equivalent for < 3.10
|
importer.exec_module(mod)
|
||||||
if hasattr(importer, "exec_module"):
|
|
||||||
importer.exec_module(mod)
|
|
||||||
else:
|
else:
|
||||||
importlib.import_module(f".{self.path}", "worlds")
|
importlib.import_module(f".{self.path}", "worlds")
|
||||||
self.time_taken = time.perf_counter()-start
|
self.time_taken = time.perf_counter()-start
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ checking or launching the client, otherwise it will probably cause circular impo
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import enum
|
import enum
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import settings
|
||||||
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
||||||
import Patch
|
import Patch
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \
|
from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \
|
||||||
get_script_version, get_system, ping
|
get_script_version, get_system, ping, display_message
|
||||||
from .client import BizHawkClient, AutoBizHawkClientRegister
|
from .client import BizHawkClient, AutoBizHawkClientRegister
|
||||||
|
|
||||||
|
|
||||||
@@ -27,20 +29,97 @@ class AuthStatus(enum.IntEnum):
|
|||||||
AUTHENTICATED = 3
|
AUTHENTICATED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class TextCategory(str, enum.Enum):
|
||||||
|
ALL = "all"
|
||||||
|
INCOMING = "incoming"
|
||||||
|
OUTGOING = "outgoing"
|
||||||
|
OTHER = "other"
|
||||||
|
HINT = "hint"
|
||||||
|
CHAT = "chat"
|
||||||
|
SERVER = "server"
|
||||||
|
|
||||||
|
|
||||||
class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
||||||
def _cmd_bh(self):
|
def _cmd_bh(self):
|
||||||
"""Shows the current status of the client's connection to BizHawk"""
|
"""Shows the current status of the client's connection to BizHawk"""
|
||||||
if isinstance(self.ctx, BizHawkClientContext):
|
assert isinstance(self.ctx, BizHawkClientContext)
|
||||||
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
|
|
||||||
logger.info("BizHawk Connection Status: Not Connected")
|
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
|
||||||
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
|
logger.info("BizHawk Connection Status: Not Connected")
|
||||||
logger.info("BizHawk Connection Status: Tentatively Connected")
|
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
|
||||||
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
|
logger.info("BizHawk Connection Status: Tentatively Connected")
|
||||||
logger.info("BizHawk Connection Status: Connected")
|
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
|
||||||
|
logger.info("BizHawk Connection Status: Connected")
|
||||||
|
|
||||||
|
def _cmd_toggle_text(self, category: str | None = None, toggle: str | None = None):
|
||||||
|
"""Sets types of incoming messages to forward to the emulator"""
|
||||||
|
assert isinstance(self.ctx, BizHawkClientContext)
|
||||||
|
|
||||||
|
if category is None:
|
||||||
|
logger.info("Usage: /toggle_text category [toggle]\n\n"
|
||||||
|
"category: incoming, outgoing, other, hint, chat, and server\n"
|
||||||
|
"Or \"all\" to toggle all categories at once\n\n"
|
||||||
|
"toggle: on, off, true, or false\n"
|
||||||
|
"Or omit to set it to the opposite of its current state\n\n"
|
||||||
|
"Example: /toggle_text outgoing on")
|
||||||
|
return
|
||||||
|
|
||||||
|
category = category.lower()
|
||||||
|
value: bool | None
|
||||||
|
if toggle is None:
|
||||||
|
value = None
|
||||||
|
elif toggle.lower() in ("on", "true"):
|
||||||
|
value = True
|
||||||
|
elif toggle.lower() in ("off", "false"):
|
||||||
|
value = False
|
||||||
|
else:
|
||||||
|
logger.info(f'Unknown value "{toggle}", should be on|off|true|false')
|
||||||
|
return
|
||||||
|
|
||||||
|
valid_categories = (
|
||||||
|
TextCategory.ALL,
|
||||||
|
TextCategory.OTHER,
|
||||||
|
TextCategory.INCOMING,
|
||||||
|
TextCategory.OUTGOING,
|
||||||
|
TextCategory.HINT,
|
||||||
|
TextCategory.CHAT,
|
||||||
|
TextCategory.SERVER,
|
||||||
|
)
|
||||||
|
if category not in valid_categories:
|
||||||
|
logger.info(f'Unknown value "{category}", should be {"|".join(valid_categories)}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if category == TextCategory.ALL:
|
||||||
|
if value is None:
|
||||||
|
logger.info('Must specify "on" or "off" for category "all"')
|
||||||
|
return
|
||||||
|
|
||||||
|
if value:
|
||||||
|
self.ctx.text_passthrough_categories.update((
|
||||||
|
TextCategory.OTHER,
|
||||||
|
TextCategory.INCOMING,
|
||||||
|
TextCategory.OUTGOING,
|
||||||
|
TextCategory.HINT,
|
||||||
|
TextCategory.CHAT,
|
||||||
|
TextCategory.SERVER,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.ctx.text_passthrough_categories.clear()
|
||||||
|
else:
|
||||||
|
if value is None:
|
||||||
|
value = category not in self.ctx.text_passthrough_categories
|
||||||
|
|
||||||
|
if value:
|
||||||
|
self.ctx.text_passthrough_categories.add(category)
|
||||||
|
else:
|
||||||
|
self.ctx.text_passthrough_categories.remove(category)
|
||||||
|
|
||||||
|
logger.info(f"Currently Showing Categories: {', '.join(self.ctx.text_passthrough_categories)}")
|
||||||
|
|
||||||
|
|
||||||
class BizHawkClientContext(CommonContext):
|
class BizHawkClientContext(CommonContext):
|
||||||
command_processor = BizHawkClientCommandProcessor
|
command_processor = BizHawkClientCommandProcessor
|
||||||
|
text_passthrough_categories: set[str]
|
||||||
server_seed_name: str | None = None
|
server_seed_name: str | None = None
|
||||||
auth_status: AuthStatus
|
auth_status: AuthStatus
|
||||||
password_requested: bool
|
password_requested: bool
|
||||||
@@ -54,12 +133,33 @@ class BizHawkClientContext(CommonContext):
|
|||||||
|
|
||||||
def __init__(self, server_address: str | None, password: str | None):
|
def __init__(self, server_address: str | None, password: str | None):
|
||||||
super().__init__(server_address, password)
|
super().__init__(server_address, password)
|
||||||
|
self.text_passthrough_categories = set()
|
||||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||||
self.password_requested = False
|
self.password_requested = False
|
||||||
self.client_handler = None
|
self.client_handler = None
|
||||||
self.bizhawk_ctx = BizHawkContext()
|
self.bizhawk_ctx = BizHawkContext()
|
||||||
self.watcher_timeout = 0.5
|
self.watcher_timeout = 0.5
|
||||||
|
|
||||||
|
def _categorize_text(self, args: dict) -> TextCategory:
|
||||||
|
if "type" not in args or args["type"] in {"Hint", "Join", "Part", "TagsChanged", "Goal", "Release", "Collect",
|
||||||
|
"Countdown", "ServerChat", "ItemCheat"}:
|
||||||
|
return TextCategory.SERVER
|
||||||
|
elif args["type"] == "Chat":
|
||||||
|
return TextCategory.CHAT
|
||||||
|
elif args["type"] == "ItemSend":
|
||||||
|
if args["item"].player == self.slot:
|
||||||
|
return TextCategory.OUTGOING
|
||||||
|
elif args["receiving"] == self.slot:
|
||||||
|
return TextCategory.INCOMING
|
||||||
|
else:
|
||||||
|
return TextCategory.OTHER
|
||||||
|
|
||||||
|
def on_print_json(self, args: dict):
|
||||||
|
super().on_print_json(args)
|
||||||
|
if self.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
|
||||||
|
if self._categorize_text(args) in self.text_passthrough_categories:
|
||||||
|
Utils.async_start(display_message(self.bizhawk_ctx, self.rawjsontotextparser(copy.deepcopy(args["data"]))))
|
||||||
|
|
||||||
def make_gui(self):
|
def make_gui(self):
|
||||||
ui = super().make_gui()
|
ui = super().make_gui()
|
||||||
ui.base_title = "Archipelago BizHawk Client"
|
ui.base_title = "Archipelago BizHawk Client"
|
||||||
@@ -205,10 +305,10 @@ async def _game_watcher(ctx: BizHawkClientContext):
|
|||||||
|
|
||||||
async def _run_game(rom: str):
|
async def _run_game(rom: str):
|
||||||
import os
|
import os
|
||||||
auto_start = Utils.get_settings().bizhawkclient_options.rom_start
|
auto_start = settings.get_settings().bizhawkclient_options.rom_start
|
||||||
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path
|
emuhawk_path = settings.get_settings().bizhawkclient_options.emuhawk_path
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[
|
[
|
||||||
emuhawk_path,
|
emuhawk_path,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class AWebInTime(WebWorld):
|
|||||||
"Multiworld Setup Guide",
|
"Multiworld Setup Guide",
|
||||||
"A guide for setting up A Hat in Time to be played in Archipelago.",
|
"A guide for setting up A Hat in Time to be played in Archipelago.",
|
||||||
"English",
|
"English",
|
||||||
"ahit_en.md",
|
"setup_en.md",
|
||||||
"setup/en",
|
"setup/en",
|
||||||
["CookieCat"]
|
["CookieCat"]
|
||||||
)]
|
)]
|
||||||
@@ -260,11 +260,7 @@ class HatInTimeWorld(World):
|
|||||||
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
|
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
|
||||||
|
|
||||||
slot_data["ShopItemNames"] = shop_item_names
|
slot_data["ShopItemNames"] = shop_item_names
|
||||||
|
slot_data.update(self.options.as_dict(*slot_data_options))
|
||||||
for name, value in self.options.as_dict(*self.options_dataclass.type_hints).items():
|
|
||||||
if name in slot_data_options:
|
|
||||||
slot_data[name] = value
|
|
||||||
|
|
||||||
return slot_data
|
return slot_data
|
||||||
|
|
||||||
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
|
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
|||||||
if localized:
|
if localized:
|
||||||
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
|
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
|
||||||
if in_dungeon_items:
|
if in_dungeon_items:
|
||||||
restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if
|
restricted_players = {world.player for world in multiworld.get_game_worlds("A Link to the Past") if
|
||||||
restricted}
|
world.options.restrict_dungeon_item_on_boss}
|
||||||
locations: typing.List["ALttPLocation"] = [
|
locations: typing.List["ALttPLocation"] = [
|
||||||
location for location in get_unfilled_dungeon_locations(multiworld)
|
location for location in get_unfilled_dungeon_locations(multiworld)
|
||||||
# filter boss
|
# filter boss
|
||||||
@@ -255,8 +255,9 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
|||||||
if all_state_base.has("Triforce", player):
|
if all_state_base.has("Triforce", player):
|
||||||
all_state_base.remove(multiworld.worlds[player].create_item("Triforce"))
|
all_state_base.remove(multiworld.worlds[player].create_item("Triforce"))
|
||||||
|
|
||||||
for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items():
|
for lttp_world in multiworld.get_game_worlds("A Link to the Past"):
|
||||||
if not key_drop_shuffle and player not in multiworld.groups:
|
if not lttp_world.options.key_drop_shuffle and lttp_world.player not in multiworld.groups:
|
||||||
|
player = lttp_world.player
|
||||||
for key_loc in key_drop_data:
|
for key_loc in key_drop_data:
|
||||||
key_data = key_drop_data[key_loc]
|
key_data = key_drop_data[key_loc]
|
||||||
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ items_reduction_table = (
|
|||||||
|
|
||||||
|
|
||||||
def generate_itempool(world):
|
def generate_itempool(world):
|
||||||
player = world.player
|
player: int = world.player
|
||||||
multiworld = world.multiworld
|
multiworld = world.multiworld
|
||||||
|
|
||||||
if world.options.item_pool.current_key not in difficulties:
|
if world.options.item_pool.current_key not in difficulties:
|
||||||
@@ -280,7 +280,6 @@ def generate_itempool(world):
|
|||||||
if multiworld.custom:
|
if multiworld.custom:
|
||||||
pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = (
|
pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = (
|
||||||
make_custom_item_pool(multiworld, player))
|
make_custom_item_pool(multiworld, player))
|
||||||
multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999)
|
|
||||||
else:
|
else:
|
||||||
(pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
|
(pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
|
||||||
additional_triforce_pieces) = get_pool_core(multiworld, player)
|
additional_triforce_pieces) = get_pool_core(multiworld, player)
|
||||||
@@ -386,8 +385,8 @@ def generate_itempool(world):
|
|||||||
|
|
||||||
if world.options.retro_bow:
|
if world.options.retro_bow:
|
||||||
shop_items = 0
|
shop_items = 0
|
||||||
shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if
|
shop_locations = [location for shop_locations in (shop.region.locations for shop in world.shops if
|
||||||
shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if
|
shop.type == ShopType.Shop) for location in shop_locations if
|
||||||
location.shop_slot is not None]
|
location.shop_slot is not None]
|
||||||
for location in shop_locations:
|
for location in shop_locations:
|
||||||
if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow":
|
if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow":
|
||||||
@@ -546,7 +545,7 @@ def set_up_take_anys(multiworld, world, player):
|
|||||||
connect_entrance(multiworld, entrance.name, old_man_take_any.name, player)
|
connect_entrance(multiworld, entrance.name, old_man_take_any.name, player)
|
||||||
entrance.target = 0x58
|
entrance.target = 0x58
|
||||||
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
|
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
|
||||||
multiworld.shops.append(old_man_take_any.shop)
|
world.shops.append(old_man_take_any.shop)
|
||||||
|
|
||||||
sword_indices = [
|
sword_indices = [
|
||||||
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
|
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
|
||||||
@@ -574,7 +573,7 @@ def set_up_take_anys(multiworld, world, player):
|
|||||||
connect_entrance(multiworld, entrance.name, take_any.name, player)
|
connect_entrance(multiworld, entrance.name, take_any.name, player)
|
||||||
entrance.target = target
|
entrance.target = target
|
||||||
take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1)
|
take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1)
|
||||||
multiworld.shops.append(take_any.shop)
|
world.shops.append(take_any.shop)
|
||||||
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
|
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
|
||||||
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0)
|
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0)
|
||||||
location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any)
|
location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
import settings
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
|
|
||||||
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
|
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
|
||||||
@@ -514,7 +515,8 @@ def _populate_sprite_table():
|
|||||||
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
|
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||||
sprite_paths = [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')]
|
sprite_paths = [user_path("data", "sprites", "alttp", "remote"),
|
||||||
|
user_path("data", "sprites", "alttp", "custom")]
|
||||||
for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]:
|
for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]:
|
||||||
for file in os.listdir(dir):
|
for file in os.listdir(dir):
|
||||||
pool.submit(load_sprite_from_file, os.path.join(dir, file))
|
pool.submit(load_sprite_from_file, os.path.join(dir, file))
|
||||||
@@ -1001,14 +1003,19 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
|
|
||||||
# set light cones
|
# set light cones
|
||||||
rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00)
|
rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00)
|
||||||
rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00)
|
# light world light cone
|
||||||
rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00)
|
rom.write_byte(0x180039, local_world.light_world_light_cone)
|
||||||
|
# dark world light cone
|
||||||
|
rom.write_byte(0x18003A, local_world.dark_world_light_cone)
|
||||||
|
|
||||||
GREEN_TWENTY_RUPEES = 0x47
|
GREEN_TWENTY_RUPEES = 0x47
|
||||||
GREEN_CLOCK = item_table["Green Clock"].item_code
|
GREEN_CLOCK = item_table["Green Clock"].item_code
|
||||||
|
|
||||||
rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on
|
rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on
|
||||||
|
|
||||||
|
# Rupoor negative value
|
||||||
|
rom.write_int16(0x180036, local_world.rupoor_cost)
|
||||||
|
|
||||||
# handle item_functionality
|
# handle item_functionality
|
||||||
if local_world.options.item_functionality == 'hard':
|
if local_world.options.item_functionality == 'hard':
|
||||||
rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon
|
rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon
|
||||||
@@ -1026,8 +1033,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
# Disable catching fairies
|
# Disable catching fairies
|
||||||
rom.write_byte(0x34FD6, 0x80)
|
rom.write_byte(0x34FD6, 0x80)
|
||||||
overflow_replacement = GREEN_TWENTY_RUPEES
|
overflow_replacement = GREEN_TWENTY_RUPEES
|
||||||
# Rupoor negative value
|
|
||||||
rom.write_int16(0x180036, world.rupoor_cost)
|
|
||||||
# Set stun items
|
# Set stun items
|
||||||
rom.write_byte(0x180180, 0x02) # Hookshot only
|
rom.write_byte(0x180180, 0x02) # Hookshot only
|
||||||
elif local_world.options.item_functionality == 'expert':
|
elif local_world.options.item_functionality == 'expert':
|
||||||
@@ -1046,8 +1051,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
# Disable catching fairies
|
# Disable catching fairies
|
||||||
rom.write_byte(0x34FD6, 0x80)
|
rom.write_byte(0x34FD6, 0x80)
|
||||||
overflow_replacement = GREEN_TWENTY_RUPEES
|
overflow_replacement = GREEN_TWENTY_RUPEES
|
||||||
# Rupoor negative value
|
|
||||||
rom.write_int16(0x180036, world.rupoor_cost)
|
|
||||||
# Set stun items
|
# Set stun items
|
||||||
rom.write_byte(0x180180, 0x00) # Nothing
|
rom.write_byte(0x180180, 0x00) # Nothing
|
||||||
else:
|
else:
|
||||||
@@ -1065,8 +1068,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
rom.write_byte(0x18004F, 0x01)
|
rom.write_byte(0x18004F, 0x01)
|
||||||
# Enable catching fairies
|
# Enable catching fairies
|
||||||
rom.write_byte(0x34FD6, 0xF0)
|
rom.write_byte(0x34FD6, 0xF0)
|
||||||
# Rupoor negative value
|
|
||||||
rom.write_int16(0x180036, world.rupoor_cost)
|
|
||||||
# Set stun items
|
# Set stun items
|
||||||
rom.write_byte(0x180180, 0x03) # All standard items
|
rom.write_byte(0x180180, 0x03) # All standard items
|
||||||
# Set overflow items for progressive equipment
|
# Set overflow items for progressive equipment
|
||||||
@@ -1312,7 +1313,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0
|
rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0
|
||||||
rom.write_byte(0xF5D73, 0xF0) # bees are catchable
|
rom.write_byte(0xF5D73, 0xF0) # bees are catchable
|
||||||
rom.write_byte(0xF5F10, 0xF0) # bees are catchable
|
rom.write_byte(0xF5F10, 0xF0) # bees are catchable
|
||||||
rom.write_byte(0x180086, 0x00 if world.aga_randomness else 0x01) # set blue ball and ganon warp randomness
|
rom.write_byte(0x180086, 0x00) # set blue ball and ganon warp randomness
|
||||||
rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror
|
rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror
|
||||||
rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp
|
rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp
|
||||||
rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00)
|
rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00)
|
||||||
@@ -1617,7 +1618,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills
|
rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills
|
||||||
rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix
|
rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix
|
||||||
rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room
|
rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room
|
||||||
rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
|
rom.write_byte(0x180042, 0x01 if local_world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
|
||||||
|
|
||||||
# remove shield from uncle
|
# remove shield from uncle
|
||||||
rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E])
|
rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E])
|
||||||
@@ -1738,8 +1739,7 @@ def get_price_data(price: int, price_type: int) -> List[int]:
|
|||||||
|
|
||||||
|
|
||||||
def write_custom_shops(rom, world, player):
|
def write_custom_shops(rom, world, player):
|
||||||
shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player],
|
shops = sorted([shop for shop in world.worlds[player].shops if shop.custom], key=lambda shop: shop.sram_offset)
|
||||||
key=lambda shop: shop.sram_offset)
|
|
||||||
|
|
||||||
shop_data = bytearray()
|
shop_data = bytearray()
|
||||||
items_data = bytearray()
|
items_data = bytearray()
|
||||||
@@ -3023,7 +3023,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def get_base_rom_path(file_name: str = "") -> str:
|
def get_base_rom_path(file_name: str = "") -> str:
|
||||||
options = Utils.get_settings()
|
options = settings.get_settings()
|
||||||
if not file_name:
|
if not file_name:
|
||||||
file_name = options["lttp_options"]["rom_file"]
|
file_name = options["lttp_options"]["rom_file"]
|
||||||
if not os.path.exists(file_name):
|
if not os.path.exists(file_name):
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ def set_defeat_dungeon_boss_rule(location):
|
|||||||
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
|
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def set_always_allow(spot, rule):
|
def set_always_allow(spot, rule):
|
||||||
spot.always_allow = rule
|
spot.always_allow = rule
|
||||||
|
|
||||||
@@ -463,12 +462,15 @@ def global_rules(multiworld: MultiWorld, player: int):
|
|||||||
set_rule(multiworld.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player))
|
set_rule(multiworld.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player))
|
||||||
set_rule(multiworld.get_location('Misery Mire - Spike Chest', player), lambda state: (world.can_take_damage and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player))
|
set_rule(multiworld.get_location('Misery Mire - Spike Chest', player), lambda state: (world.can_take_damage and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player))
|
||||||
set_rule(multiworld.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player))
|
set_rule(multiworld.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player))
|
||||||
# How to access crystal switch:
|
|
||||||
# If have big key: then you will need 2 small keys to be able to hit switch and return to main area, as you can burn key in dark room
|
# The most number of keys you can burn without opening the map chest and without reaching a crystal switch is 1,
|
||||||
# If not big key: cannot burn key in dark room, hence need only 1 key. all doors immediately available lead to a crystal switch.
|
# but if you cannot activate a crystal switch except by throwing a pot, you could burn another two going through
|
||||||
# The listed chests are those which can be reached if you can reach a crystal switch.
|
# the conveyor crystal room.
|
||||||
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
|
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 2) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 4))
|
||||||
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
|
# Using a key on the map door chest will get you the map chest but not a crystal switch. Main Lobby should require
|
||||||
|
# one more key.
|
||||||
|
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 3) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 5))
|
||||||
|
|
||||||
# we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet
|
# we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet
|
||||||
set_rule(multiworld.get_location('Misery Mire - Conveyor Crystal Key Drop', player),
|
set_rule(multiworld.get_location('Misery Mire - Conveyor Crystal Key Drop', player),
|
||||||
lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4)
|
lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4)
|
||||||
@@ -542,6 +544,8 @@ def global_rules(multiworld: MultiWorld, player: int):
|
|||||||
set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player))
|
set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player))
|
||||||
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player))
|
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player))
|
||||||
set_rule(multiworld.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
set_rule(multiworld.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
||||||
|
set_rule(multiworld.get_location('Ganons Tower - Double Switch Pot Key', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
|
||||||
|
set_rule(multiworld.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
|
||||||
if world.options.pot_shuffle:
|
if world.options.pot_shuffle:
|
||||||
set_rule(multiworld.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
set_rule(multiworld.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
||||||
set_rule(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
|
set_rule(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
|
||||||
@@ -975,18 +979,19 @@ def check_is_dark_world(region):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def add_conditional_lamps(world, player):
|
def add_conditional_lamps(multiworld, player):
|
||||||
# Light cones in standard depend on which world we actually are in, not which one the location would normally be
|
# Light cones in standard depend on which world we actually are in, not which one the location would normally be
|
||||||
# We add Lamp requirements only to those locations which lie in the dark world (or everything if open
|
# We add Lamp requirements only to those locations which lie in the dark world (or everything if open
|
||||||
|
local_world = multiworld.worlds[player]
|
||||||
|
|
||||||
def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False):
|
def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False):
|
||||||
if (not world.dark_world_light_cone and check_is_dark_world(world.get_region(region, player))) or (
|
if (not local_world.dark_world_light_cone and check_is_dark_world(local_world.get_region(region))) or (
|
||||||
not world.light_world_light_cone and not check_is_dark_world(world.get_region(region, player))):
|
not local_world.light_world_light_cone and not check_is_dark_world(local_world.get_region(region))):
|
||||||
if spottype == 'Location':
|
if spottype == 'Location':
|
||||||
spot = world.get_location(spot, player)
|
spot = local_world.get_location(spot)
|
||||||
else:
|
else:
|
||||||
spot = world.get_entrance(spot, player)
|
spot = local_world.get_entrance(spot)
|
||||||
add_lamp_requirement(world, spot, player, accessible_torch)
|
add_lamp_requirement(multiworld, spot, player, accessible_torch)
|
||||||
|
|
||||||
add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance')
|
add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance')
|
||||||
add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance')
|
add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance')
|
||||||
@@ -997,7 +1002,7 @@ def add_conditional_lamps(world, player):
|
|||||||
'Location', True)
|
'Location', True)
|
||||||
add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)',
|
add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)',
|
||||||
'Location', True)
|
'Location', True)
|
||||||
if world.worlds[player].options.mode != 'inverted':
|
if multiworld.worlds[player].options.mode != 'inverted':
|
||||||
add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance')
|
add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance')
|
||||||
add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower')
|
add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower')
|
||||||
add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower')
|
add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower')
|
||||||
@@ -1019,10 +1024,10 @@ def add_conditional_lamps(world, player):
|
|||||||
add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True)
|
add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True)
|
||||||
add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True)
|
add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True)
|
||||||
|
|
||||||
if not world.worlds[player].options.mode == "standard":
|
if not multiworld.worlds[player].options.mode == "standard":
|
||||||
add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player)
|
add_lamp_requirement(multiworld, local_world.get_location("Sewers - Dark Cross"), player)
|
||||||
add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player)
|
add_lamp_requirement(multiworld, local_world.get_entrance("Sewers Back Door"), player)
|
||||||
add_lamp_requirement(world, world.get_entrance('Throne Room', player), player)
|
add_lamp_requirement(multiworld, local_world.get_entrance("Throne Room"), player)
|
||||||
|
|
||||||
|
|
||||||
def open_rules(world, player):
|
def open_rules(world, player):
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ from .Items import item_name_groups
|
|||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@unique
|
@unique
|
||||||
class ShopType(IntEnum):
|
class ShopType(IntEnum):
|
||||||
@@ -162,7 +160,10 @@ shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop,
|
|||||||
|
|
||||||
|
|
||||||
def push_shop_inventories(multiworld):
|
def push_shop_inventories(multiworld):
|
||||||
shop_slots = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop.type
|
all_shops = []
|
||||||
|
for world in multiworld.get_game_worlds(ALttPLocation.game):
|
||||||
|
all_shops.extend(world.shops)
|
||||||
|
shop_slots = [location for shop_locations in (shop.region.locations for shop in all_shops if shop.type
|
||||||
!= ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None]
|
!= ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None]
|
||||||
|
|
||||||
for location in shop_slots:
|
for location in shop_slots:
|
||||||
@@ -178,7 +179,7 @@ def push_shop_inventories(multiworld):
|
|||||||
get_price(multiworld, location.shop.inventory[location.shop_slot], location.player,
|
get_price(multiworld, location.shop.inventory[location.shop_slot], location.player,
|
||||||
location.shop_price_type)[1])
|
location.shop_price_type)[1])
|
||||||
|
|
||||||
for world in multiworld.get_game_worlds("A Link to the Past"):
|
for world in multiworld.get_game_worlds(ALttPLocation.game):
|
||||||
world.pushed_shop_inventories.set()
|
world.pushed_shop_inventories.set()
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ def create_shops(multiworld, player: int):
|
|||||||
if locked is None:
|
if locked is None:
|
||||||
shop.locked = True
|
shop.locked = True
|
||||||
region.shop = shop
|
region.shop = shop
|
||||||
multiworld.shops.append(shop)
|
multiworld.worlds[player].shops.append(shop)
|
||||||
for index, item in enumerate(inventory):
|
for index, item in enumerate(inventory):
|
||||||
shop.add_inventory(index, *item)
|
shop.add_inventory(index, *item)
|
||||||
if not locked and (num_slots or type == ShopType.UpgradeShop):
|
if not locked and (num_slots or type == ShopType.UpgradeShop):
|
||||||
@@ -309,50 +310,50 @@ def set_up_shops(multiworld, player: int):
|
|||||||
from .Options import small_key_shuffle
|
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.worlds[player].options.retro_bow:
|
local_world = multiworld.worlds[player]
|
||||||
|
|
||||||
|
if local_world.options.retro_bow:
|
||||||
rss = multiworld.get_region('Red Shield Shop', player).shop
|
rss = multiworld.get_region('Red Shield Shop', player).shop
|
||||||
|
# Can't just replace the single arrow with 10 arrows as retro doesn't need them.
|
||||||
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
|
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
|
||||||
['Blue Shield', 50], ['Small Heart',
|
['Blue Shield', 50], ['Small Heart', 10]]
|
||||||
10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them.
|
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
|
|
||||||
replacement_items.append(['Small Key (Universal)', 100])
|
replacement_items.append(['Small Key (Universal)', 100])
|
||||||
replacement_item = multiworld.random.choice(replacement_items)
|
replacement_item = multiworld.random.choice(replacement_items)
|
||||||
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
|
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
|
||||||
rss.locked = True
|
rss.locked = True
|
||||||
|
|
||||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal or multiworld.worlds[player].options.retro_bow:
|
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal or local_world.options.retro_bow:
|
||||||
for shop in multiworld.random.sample([s for s in multiworld.shops if
|
for shop in multiworld.random.sample([s for s in local_world.shops if
|
||||||
s.custom and not s.locked and s.type == ShopType.Shop
|
s.custom and not s.locked and s.type == ShopType.Shop], 5):
|
||||||
and s.region.player == player], 5):
|
|
||||||
shop.locked = True
|
shop.locked = True
|
||||||
slots = [0, 1, 2]
|
slots = [0, 1, 2]
|
||||||
multiworld.random.shuffle(slots)
|
multiworld.random.shuffle(slots)
|
||||||
slots = iter(slots)
|
slots = iter(slots)
|
||||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
|
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||||
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
|
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
|
||||||
if multiworld.worlds[player].options.retro_bow:
|
if local_world.options.retro_bow:
|
||||||
shop.push_inventory(next(slots), 'Single Arrow', 80)
|
shop.push_inventory(next(slots), 'Single Arrow', 80)
|
||||||
|
|
||||||
if multiworld.worlds[player].options.shuffle_capacity_upgrades:
|
if local_world.options.shuffle_capacity_upgrades:
|
||||||
for shop in multiworld.shops:
|
for shop in local_world.shops:
|
||||||
if shop.type == ShopType.UpgradeShop and shop.region.player == player and \
|
if shop.type == ShopType.UpgradeShop and \
|
||||||
shop.region.name == "Capacity Upgrade":
|
shop.region.name == "Capacity Upgrade":
|
||||||
shop.clear_inventory()
|
shop.clear_inventory()
|
||||||
|
|
||||||
if (multiworld.worlds[player].options.shuffle_shop_inventories or multiworld.worlds[player].options.randomize_shop_prices
|
if (local_world.options.shuffle_shop_inventories or local_world.options.randomize_shop_prices
|
||||||
or multiworld.worlds[player].options.randomize_cost_types):
|
or local_world.options.randomize_cost_types):
|
||||||
shops = []
|
shops = []
|
||||||
total_inventory = []
|
total_inventory = []
|
||||||
for shop in multiworld.shops:
|
for shop in local_world.shops:
|
||||||
if shop.region.player == player:
|
if shop.type == ShopType.Shop and not shop.locked:
|
||||||
if shop.type == ShopType.Shop and not shop.locked:
|
shops.append(shop)
|
||||||
shops.append(shop)
|
total_inventory.extend(shop.inventory)
|
||||||
total_inventory.extend(shop.inventory)
|
|
||||||
|
|
||||||
for item in total_inventory:
|
for item in total_inventory:
|
||||||
item["price_type"], item["price"] = get_price(multiworld, item, player)
|
item["price_type"], item["price"] = get_price(multiworld, item, player)
|
||||||
|
|
||||||
if multiworld.worlds[player].options.shuffle_shop_inventories:
|
if local_world.options.shuffle_shop_inventories:
|
||||||
multiworld.random.shuffle(total_inventory)
|
multiworld.random.shuffle(total_inventory)
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
@@ -407,7 +408,7 @@ price_rate_display = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_price_modifier(item):
|
def get_price_modifier(item) -> float:
|
||||||
if item.game == "A Link to the Past":
|
if item.game == "A Link to the Past":
|
||||||
if any(x in item.name for x in
|
if any(x in item.name for x in
|
||||||
['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']):
|
['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']):
|
||||||
@@ -418,9 +419,9 @@ def get_price_modifier(item):
|
|||||||
elif any(x in item.name for x in ['Small Key', 'Heart']):
|
elif any(x in item.name for x in ['Small Key', 'Heart']):
|
||||||
return 0.5
|
return 0.5
|
||||||
else:
|
else:
|
||||||
return 1
|
return 1.0
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
return 1
|
return 1.0
|
||||||
elif item.useful:
|
elif item.useful:
|
||||||
return 0.5
|
return 0.5
|
||||||
else:
|
else:
|
||||||
@@ -471,7 +472,7 @@ def get_price(multiworld, item, player: int, price_type=None):
|
|||||||
|
|
||||||
def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation):
|
def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation):
|
||||||
if location.shop_price_type == ShopPriceType.Hearts:
|
if location.shop_price_type == ShopPriceType.Hearts:
|
||||||
return has_hearts(state, player, (location.shop_price / 8) + 1)
|
return has_hearts(state, player, (location.shop_price // 8) + 1)
|
||||||
elif location.shop_price_type == ShopPriceType.Bombs:
|
elif location.shop_price_type == ShopPriceType.Bombs:
|
||||||
return can_use_bombs(state, player, location.shop_price)
|
return can_use_bombs(state, player, location.shop_price)
|
||||||
elif location.shop_price_type == ShopPriceType.Arrows:
|
elif location.shop_price_type == ShopPriceType.Arrows:
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
|
|||||||
|
|
||||||
|
|
||||||
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
|
return any(shop.has_unlimited(item) and shop.region.can_reach(state) for
|
||||||
shop in state.multiworld.shops)
|
shop in state.multiworld.worlds[player].shops)
|
||||||
|
|
||||||
|
|
||||||
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
|
return any(shop.has(item) and shop.region.can_reach(state) for
|
||||||
shop in state.multiworld.shops)
|
shop in state.multiworld.worlds[player].shops)
|
||||||
|
|
||||||
|
|
||||||
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
|
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ class ALTTPWorld(World):
|
|||||||
required_client_version = (0, 4, 1)
|
required_client_version = (0, 4, 1)
|
||||||
web = ALTTPWeb()
|
web = ALTTPWeb()
|
||||||
|
|
||||||
|
shops: list[Shop]
|
||||||
|
|
||||||
pedestal_credit_texts: typing.Dict[int, str] = \
|
pedestal_credit_texts: typing.Dict[int, str] = \
|
||||||
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
|
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
|
||||||
sickkid_credit_texts: typing.Dict[int, str] = \
|
sickkid_credit_texts: typing.Dict[int, str] = \
|
||||||
@@ -282,6 +284,10 @@ class ALTTPWorld(World):
|
|||||||
clock_mode: str = ""
|
clock_mode: str = ""
|
||||||
treasure_hunt_required: int = 0
|
treasure_hunt_required: int = 0
|
||||||
treasure_hunt_total: int = 0
|
treasure_hunt_total: int = 0
|
||||||
|
light_world_light_cone: bool = False
|
||||||
|
dark_world_light_cone: bool = False
|
||||||
|
save_and_quit_from_boss: bool = True
|
||||||
|
rupoor_cost: int = 10
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.dungeon_local_item_names = set()
|
self.dungeon_local_item_names = set()
|
||||||
@@ -298,6 +304,7 @@ class ALTTPWorld(World):
|
|||||||
self.fix_trock_exit = None
|
self.fix_trock_exit = None
|
||||||
self.required_medallions = ["Ether", "Quake"]
|
self.required_medallions = ["Ether", "Quake"]
|
||||||
self.escape_assist = []
|
self.escape_assist = []
|
||||||
|
self.shops = []
|
||||||
super(ALTTPWorld, self).__init__(*args, **kwargs)
|
super(ALTTPWorld, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -505,10 +512,11 @@ class ALTTPWorld(World):
|
|||||||
def pre_fill(self):
|
def pre_fill(self):
|
||||||
from Fill import fill_restrictive, FillError
|
from Fill import fill_restrictive, FillError
|
||||||
attempts = 5
|
attempts = 5
|
||||||
all_state = self.multiworld.get_all_state(use_cache=False)
|
all_state = self.multiworld.get_all_state(perform_sweep=False)
|
||||||
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
|
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
|
||||||
for crystal in crystals:
|
for crystal in crystals:
|
||||||
all_state.remove(crystal)
|
all_state.remove(crystal)
|
||||||
|
all_state.sweep_for_advancements()
|
||||||
crystal_locations = [self.get_location('Turtle Rock - Prize'),
|
crystal_locations = [self.get_location('Turtle Rock - Prize'),
|
||||||
self.get_location('Eastern Palace - Prize'),
|
self.get_location('Eastern Palace - Prize'),
|
||||||
self.get_location('Desert Palace - Prize'),
|
self.get_location('Desert Palace - Prize'),
|
||||||
@@ -799,7 +807,7 @@ class ALTTPWorld(World):
|
|||||||
|
|
||||||
return shop_data
|
return shop_data
|
||||||
|
|
||||||
if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]:
|
if shop_info := [build_shop_info(shop) for shop in self.shops if shop.custom]:
|
||||||
spoiler_handle.write('\n\nShops:\n\n')
|
spoiler_handle.write('\n\nShops:\n\n')
|
||||||
for shop_data in shop_info:
|
for shop_data in shop_info:
|
||||||
spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join(
|
spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join(
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class TestMiseryMire(TestDungeon):
|
|||||||
["Misery Mire - Main Lobby", False, []],
|
["Misery Mire - Main Lobby", False, []],
|
||||||
["Misery Mire - Main Lobby", False, [], ['Pegasus Boots', 'Hookshot']],
|
["Misery Mire - Main Lobby", False, [], ['Pegasus Boots', 'Hookshot']],
|
||||||
["Misery Mire - Main Lobby", False, [], ['Small Key (Misery Mire)', 'Big Key (Misery Mire)']],
|
["Misery Mire - Main Lobby", False, [], ['Small Key (Misery Mire)', 'Big Key (Misery Mire)']],
|
||||||
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
|
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
|
||||||
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
|
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
|
||||||
|
|
||||||
["Misery Mire - Big Key Chest", False, []],
|
["Misery Mire - Big Key Chest", False, []],
|
||||||
["Misery Mire - Big Key Chest", False, [], ['Fire Rod', 'Lamp']],
|
["Misery Mire - Big Key Chest", False, [], ['Fire Rod', 'Lamp']],
|
||||||
|
|||||||
@@ -207,11 +207,7 @@ class EnemyScaling(DefaultOnToggle):
|
|||||||
|
|
||||||
|
|
||||||
class BlasphemousDeathLink(DeathLink):
|
class BlasphemousDeathLink(DeathLink):
|
||||||
"""
|
__doc__ = DeathLink.__doc__ + "\n\n Note that Guilt Fragments will not appear when killed by death link."
|
||||||
When you die, everyone dies. The reverse is also true.
|
|
||||||
|
|
||||||
Note that Guilt Fragments will not appear when killed by Death Link.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -175,11 +175,7 @@ class DamageMultiplier(Range):
|
|||||||
|
|
||||||
|
|
||||||
class BRCDeathLink(DeathLink):
|
class BRCDeathLink(DeathLink):
|
||||||
"""
|
__doc__ = DeathLink.__doc__ + "\n\n This can be changed later in the options menu inside the Archipelago phone app."
|
||||||
When you die, everyone dies. The reverse is also true.
|
|
||||||
|
|
||||||
This can be changed later in the options menu inside the Archipelago phone app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_IRON_WORKING",
|
"BOOST_TECH_IRON_WORKING",
|
||||||
"ERA_CLASSICAL",
|
"ERA_CLASSICAL",
|
||||||
["TECH_MINING"],
|
["TECH_MINING", "TECH_BRONZE_WORKING"],
|
||||||
1,
|
2,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -165,15 +165,9 @@ boosts: List[CivVIBoostData] = [
|
|||||||
"BOOST_TECH_CASTLES",
|
"BOOST_TECH_CASTLES",
|
||||||
"ERA_MEDIEVAL",
|
"ERA_MEDIEVAL",
|
||||||
[
|
[
|
||||||
"CIVIC_DIVINE_RIGHT",
|
|
||||||
"CIVIC_EXPLORATION",
|
|
||||||
"CIVIC_REFORMED_CHURCH",
|
|
||||||
"CIVIC_SUFFRAGE",
|
"CIVIC_SUFFRAGE",
|
||||||
"CIVIC_TOTALITARIANISM",
|
"CIVIC_TOTALITARIANISM",
|
||||||
"CIVIC_CLASS_STRUGGLE",
|
"CIVIC_CLASS_STRUGGLE",
|
||||||
"CIVIC_DIGITAL_DEMOCRACY",
|
|
||||||
"CIVIC_CORPORATE_LIBERTARIANISM",
|
|
||||||
"CIVIC_SYNTHETIC_TECHNOCRACY",
|
|
||||||
],
|
],
|
||||||
1,
|
1,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
@@ -393,9 +387,6 @@ boosts: List[CivVIBoostData] = [
|
|||||||
"CIVIC_SUFFRAGE",
|
"CIVIC_SUFFRAGE",
|
||||||
"CIVIC_TOTALITARIANISM",
|
"CIVIC_TOTALITARIANISM",
|
||||||
"CIVIC_CLASS_STRUGGLE",
|
"CIVIC_CLASS_STRUGGLE",
|
||||||
"CIVIC_DIGITAL_DEMOCRACY",
|
|
||||||
"CIVIC_CORPORATE_LIBERTARIANISM",
|
|
||||||
"CIVIC_SYNTHETIC_TECHNOCRACY",
|
|
||||||
],
|
],
|
||||||
1,
|
1,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
from BaseClasses import Item, ItemClassification
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import CliqueWorld
|
|
||||||
|
|
||||||
|
|
||||||
class CliqueItem(Item):
|
|
||||||
game = "Clique"
|
|
||||||
|
|
||||||
|
|
||||||
class CliqueItemData(NamedTuple):
|
|
||||||
code: Optional[int] = None
|
|
||||||
type: ItemClassification = ItemClassification.filler
|
|
||||||
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
|
|
||||||
|
|
||||||
|
|
||||||
item_data_table: Dict[str, CliqueItemData] = {
|
|
||||||
"Feeling of Satisfaction": CliqueItemData(
|
|
||||||
code=69696969,
|
|
||||||
type=ItemClassification.progression,
|
|
||||||
),
|
|
||||||
"Button Activation": CliqueItemData(
|
|
||||||
code=69696968,
|
|
||||||
type=ItemClassification.progression,
|
|
||||||
can_create=lambda world: world.options.hard_mode,
|
|
||||||
),
|
|
||||||
"A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
|
|
||||||
code=69696967,
|
|
||||||
can_create=lambda world: False # Only created from `get_filler_item_name`.
|
|
||||||
),
|
|
||||||
"The Urge to Push": CliqueItemData(
|
|
||||||
type=ItemClassification.progression,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
item_table = {name: data.code for name, data in item_data_table.items() if data.code is not None}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
from BaseClasses import Location
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import CliqueWorld
|
|
||||||
|
|
||||||
|
|
||||||
class CliqueLocation(Location):
|
|
||||||
game = "Clique"
|
|
||||||
|
|
||||||
|
|
||||||
class CliqueLocationData(NamedTuple):
|
|
||||||
region: str
|
|
||||||
address: Optional[int] = None
|
|
||||||
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
|
|
||||||
locked_item: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
location_data_table: Dict[str, CliqueLocationData] = {
|
|
||||||
"The Big Red Button": CliqueLocationData(
|
|
||||||
region="The Button Realm",
|
|
||||||
address=69696969,
|
|
||||||
),
|
|
||||||
"The Item on the Desk": CliqueLocationData(
|
|
||||||
region="The Button Realm",
|
|
||||||
address=69696968,
|
|
||||||
can_create=lambda world: world.options.hard_mode,
|
|
||||||
),
|
|
||||||
"In the Player's Mind": CliqueLocationData(
|
|
||||||
region="The Button Realm",
|
|
||||||
locked_item="The Urge to Push",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
location_table = {name: data.address for name, data in location_data_table.items() if data.address is not None}
|
|
||||||
locked_locations = {name: data for name, data in location_data_table.items() if data.locked_item}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool
|
|
||||||
|
|
||||||
|
|
||||||
class HardMode(Toggle):
|
|
||||||
"""Only for the most masochistically inclined... Requires button activation!"""
|
|
||||||
display_name = "Hard Mode"
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonColor(Choice):
|
|
||||||
"""Customize your button! Now available in 12 unique colors."""
|
|
||||||
display_name = "Button Color"
|
|
||||||
option_red = 0
|
|
||||||
option_orange = 1
|
|
||||||
option_yellow = 2
|
|
||||||
option_green = 3
|
|
||||||
option_cyan = 4
|
|
||||||
option_blue = 5
|
|
||||||
option_magenta = 6
|
|
||||||
option_purple = 7
|
|
||||||
option_pink = 8
|
|
||||||
option_brown = 9
|
|
||||||
option_white = 10
|
|
||||||
option_black = 11
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CliqueOptions(PerGameCommonOptions):
|
|
||||||
color: ButtonColor
|
|
||||||
hard_mode: HardMode
|
|
||||||
start_inventory_from_pool: StartInventoryPool
|
|
||||||
|
|
||||||
# DeathLink is always on. Always.
|
|
||||||
# death_link: DeathLink
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from typing import Dict, List, NamedTuple
|
|
||||||
|
|
||||||
|
|
||||||
class CliqueRegionData(NamedTuple):
|
|
||||||
connecting_regions: List[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
region_data_table: Dict[str, CliqueRegionData] = {
|
|
||||||
"Menu": CliqueRegionData(["The Button Realm"]),
|
|
||||||
"The Button Realm": CliqueRegionData(),
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from typing import Callable, TYPE_CHECKING
|
|
||||||
|
|
||||||
from BaseClasses import CollectionState
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import CliqueWorld
|
|
||||||
|
|
||||||
|
|
||||||
def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]:
|
|
||||||
if world.options.hard_mode:
|
|
||||||
return lambda state: state.has("Button Activation", world.player)
|
|
||||||
|
|
||||||
return lambda state: True
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user