Compare commits

..

63 Commits

Author SHA1 Message Date
Fabian Dill
db85a7f554 Merge branch 'main' into setup_more_apworld 2025-11-09 02:10:05 +01:00
Vertraic
ecadb301c0 Core: Allows Meta.yaml to add triggers to individual yaml's categories. (#3556)
* Initial commit

* Shifted added code to the appropriate indentation.
Re-wrote for statement in proper python style.

* Update Generate.py

Co-authored-by: qwint <qwint.42@gmail.com>

* change to an elif to avoid unnecessary nesting

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Benny D <78334662+benny-dreamly@users.noreply.github.com>
2025-11-08 23:45:26 +00:00
black-sliver
360ad7197b CI: downgrade pytest to 8.4.2 (#5613)
Also move ci requirements to separate file for easier handling.
2025-11-09 00:05:36 +01:00
Yaranorgoth
96ae2235d1 CCCharles: Fix editorial issues in documentations (#5611)
* Fix editorial issues from Setup Guides

* Fix editorial issues in documentations

* Fix extra typos in documentations
2025-11-08 23:10:36 +01:00
Jacob Lewis
37b87e3fde [Docs] Update docs/network protocol.md/NetworkVersion to include class field (#5377)
* update docs NetworkVersion

* added in non-common-client version clarification

* Update docs/network protocol.md

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-11-08 22:15:29 +01:00
Adrian Priestley
5b6714d2c0 chore(documentation): Update deployment example config (#5476)
- Include flag and notice regarding asset rights in example config
2025-11-08 17:21:27 +01:00
LiquidCat64
97c07e91d1 CVCotM: Fix determinism with Halve DSS Cards Placed (#5601) 2025-11-03 19:31:36 +01:00
black-sliver
7cd7111241 CI: use rehosted appimage runtime and appimagetool (#5595)
This fixes the problem of CI randomly breaking when upstream pushes
updates and allows better reproducibility of builds.
2025-10-31 08:34:31 +01:00
NewSoupVi
4b0306102d WebHost: Pin Flask-Compress to 1.18 for all versions of Python (#5590)
* WebHost: Pin Flask-Compress to 1.18 for all versions of Python

* oop
2025-10-26 11:40:21 +01:00
LiquidCat64
3f139f2efb CV64: Fix Explosive DeathLink not working with Increase Shimmy Speed on #5523 2025-10-26 11:39:14 +01:00
Subsourian
41a62a1a9e SC2: added MindHawk to credits (#5549) 2025-10-26 08:54:17 +01:00
black-sliver
8837e617e4 WebHost, Multiple Worlds: fix images not showing in guides (#5576)
* Multiple: resize FR RA network commands screenshot

This is now more in line with the text (and the english version).

* Multiple: optimize EN RA network commands screenshot

The URL has changed, so it's a good time to optimize.

* WebHost, Worlds: fix retroarch images not showing

Implements a src/url replacement for relative paths.
Moves the RA screenshots to worlds/generic since they are shared.
Also now uses the FR version in ffmq.
Also fixes the formatting that resultet in the list breaking.
Also moves imports in render_markdown.

Guides now also properly render on Github.

* Factorio: optimize screenshots

The URL has changed, so it's a good time to optimize.

* Factorio: change guide screenshots to use relative URL

* Test: markdown: fix tests on Windows

We also can't use delete=True, delete_on_close=False
because that's not supported in Py3.11.

* Test: markdown: fix typo

I hope that's it now. *sigh*

* Landstalker: fix doc images not showing

Change to relative img urls.

* Landstalker: optimize doc PNGs

The URL has changed, so it's a good time to optimize.
2025-10-25 22:19:38 +02:00
black-sliver
2bf410f285 CI: update appimagetool to 2025-10-19 (#5578)
Beware: this has a bug, but it does not impact our CI.
2025-10-25 16:49:05 +00:00
NewSoupVi
04fe43d53a kvui: Fix audio being completely non-functional on Linux (#5588)
* kvui: Fix audio on Linux

* Update kvui.py
2025-10-25 15:34:59 +02:00
NewSoupVi
643f61e7f4 Core: Add a ruff.toml to the root directory (#5259)
* Add a ruff.toml to the root directory

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-25 00:19:42 +02:00
black-sliver
6b91ffecf1 WebHost: add missing docutils requirement ... (#5583)
... and update it to latest.
This is being used in WebHostLib.options directly.
A recent change bumped our required version, so this is actually a fix.
2025-10-24 00:55:10 +02:00
black-sliver
4f7f092b9b setup: check if the sign host is on a local network (#5501)
Could have a really bad timeout if it goes through default route and packet is dropped.
2025-10-24 00:54:27 +02:00
gaithern
df3c6b7980 KH1: Add specified encoding to file output from Client to avoid crashes with non ASCII characters (#5584)
* Fix Slot 2 Level Checks description

* Fix encoding issue
2025-10-23 23:01:02 +02:00
threeandthreee
19839399e5 LADX: stealing logic option (#3965)
* implement StealingInLogic option

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

sword to front for readability, but also can_farm condition was missing
2025-10-23 22:11:41 +02:00
CookieCat
4847be98d2 AHIT: Fix death link timestamps being incorrect (#5404) 2025-10-23 05:30:46 +02:00
black-sliver
3105320038 Test: check fields in world source manifest (#5558)
* Test: check game in world manifest

* Update test/general/test_world_manifest.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

according what was added to docs/apworld specification.md in PR 5509

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-10-22 01:52:44 +02:00
Silvris
e8c8b0dbc5 MM2: fix Proteus reading #5575 2025-10-21 19:10:39 +02:00
Duck
c199775c48 Pokemon RB: Fix likely unintended concatenation #5566 2025-10-20 21:48:17 +02:00
Duck
d2bf7fdaf7 AHiT: Fix likely unintended concatenation #5565 2025-10-20 21:47:49 +02:00
Duck
621ec274c3 Yugioh: Fix likely unintended concatenations (#5567)
* Fix likely unintended concatenations

* Yeah that makes sense why I thought there were more here
2025-10-20 21:47:16 +02:00
NewSoupVi
7cd73e2710 WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541)
* Fix webhost argparse with extra args

* accidentally added line

* WebHost: fix some typing

B64 url conversion is used in test/hosting,
so it felt appropriate to include this here.

* Test: Hosting: also test autogen

* Test: Hosting: simplify stop_* and leave a note about Windows compat

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

There are actually some differences with MP on Windows
that make it impossible to run this in CI.

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-20 17:40:32 +02:00
NewSoupVi
708df4d1e2 WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again) (#5573)
From Discord:

Well, flask-compress updated and now our 3.11 CI is failing

Why? They switched to a lib called backports.zstd
And 3.11 pkg_resources can't handle that.

pip finds it. But in our ModuleUpdate.py, we first pkg_resources.require packages, and this fails. I can't reproduce this locally yet, but in CI, it seems like even though backports.zstd is installed, it still fails on it and prompts installing it over and over in every unit test
Now what do we do :KEKW:
Black Sliver suggested pinning flask-compress for 3.11
But I would just like to point out that this means we can't unpin it until we drop 3.11
the real thing is we probably need to move away from pkg_resources? lol 
since it's been deprecated literally since the oldest version we support
2025-10-20 17:06:07 +02:00
black-sliver
914a534a3b WebHost: fix gen timeout/exception resource handling (#5540)
* WebHost: reset Generator proc title on error

* WebHost: fix shutting down autogen

This is still not perfect but solves some of the issues.

* WebHost: properly propagate JOB_TIME

* WebHost: handle autogen shutdown
2025-10-20 09:16:29 +02:00
NewSoupVi
11d18db452 Docs: APWorld documentation, make a distinction between APWorld and .apworld (#5509)
* APWorld docs: Make a distinction between APWorld and .apworld

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

* Update apworld specification.md
2025-10-19 09:05:34 +02:00
Nicholas Saylor
00acfe63d4 WebHost: Update publish_parts parameters (#5544)
old name is deprecated and new name allows both writer instance or alias/name.
2025-10-19 03:40:25 +02:00
Fafale
2ac9ab5337 Docs: add warning about BepInEx to HK translated setup guides (#5554)
* Update HK pt-br setup to add warning about BepInEx

* Update HK spanish setup guide to add warning about BepInEx
2025-10-19 03:36:35 +02:00
Benny D
2569c9e531 DLC Quest: Enable multi-classification items (#5552)
* implement prog trap item (thanks stardew)

* oops that's wrong

* okay this is right
2025-10-19 03:30:24 +02:00
Rosalie
946f227226 [FF1] Added Deep Dungeon locations to locations.json so they exist in the datapackage (#5392)
* Added DD locations to locations.json so they exist in the datapackage.

* Update worlds/ff1/data/locations.json

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

Co-authored-by: qwint <qwint.42@gmail.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-17 16:44:11 +02:00
Carter Hesterman
7ead8fdf49 Civ 6: Add era requirements for boosts and update boost prereqs (#5296)
* Resolve #5136

* Resolves #5210
2025-10-17 16:35:44 +02:00
Rosalie
f5f554cb3d [FF1] Client fix and improvement (#5390)
* FF1 Client fixes.

* Strip leading/trailing spaces from rom-stored player name.

* FF1R encodes the name as utf-8, as it happens.

* UTF-8 is four bytes per character, so we need 64 bytes for the name, not 16.
2025-10-17 16:34:10 +02:00
Alchav
3f2942c599 Super Mario Land 2: Logic fixes #5258
Co-authored-by: alchav <alchav@jalchavware.com>
2025-10-17 16:32:58 +02:00
Snarky
da519e7f73 SC2: fix incorrect preset option (#5551)
* SC2: fix incorrect preset option

* SC2: fix incorrect evil logic preset option

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
2025-10-17 16:30:05 +02:00
Duck
0718ada682 Core: Allow PlandoItems to be pickled (#5335)
* Add Options.PlandoItem

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

* Use deprecated decorator
2025-10-17 03:20:34 +02:00
Duck
f756919dd9 CI: Add worlds manifests to build action trigger (#5555)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-16 23:58:12 +02:00
Jérémie Bolduc
406b905dc8 Stardew Valley: Add archipelago.json (#5535)
* add apworld manifest

* add world version
2025-10-16 22:23:23 +02:00
JaredWeakStrike
91439e0fb0 KH2: Manifest eletric boogaloo (#5556)
* manifest file

* x y z for world version

* Update archipelago.json
2025-10-16 20:25:11 +02:00
RoobyRoo
03bd59bff6 Ocarina of Time: Create manifest (#5536)
* Create archipelago.json

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-16 11:48:04 +02:00
BlastSlimey
cf02e1a1aa shapez: Fix floating layers logic error #5263 2025-10-15 23:41:15 +02:00
JaredWeakStrike
f6d696ea62 KH2: Manifest File (#5553)
* manifest file

* x y z for world version
2025-10-15 23:40:21 +02:00
BadMagic100
123acdef23 Docs: warn HK users not to use BepInEx #5550 2025-10-15 13:35:00 +02:00
Nicholas Saylor
28c7a214dc Core: Use Better Practices Accessing Manifests (#5543)
* Close manifest files

* Name explicit encoding
2025-10-15 01:09:05 +02:00
NewSoupVi
bdae7cd42c MultiServer: Fix hinting multi-copy items bleeding found status (#5547)
* fix hinting multi-copy items bleeding found status

* reword
2025-10-14 20:44:01 +02:00
Silvris
fc404d0cf7 MM2: fix Heat Man always being invulnerable to Atomic Fire #5546 2025-10-14 09:27:41 +02:00
threeandthreee
5ce71db048 LADX: use start_inventory_from_pool (#4641) 2025-10-13 19:32:49 +02:00
NewSoupVi
aff98a5b78 CommonClient: Fix manually connecting to a url when the username or password has a space in it (#5528)
* CommonClient: Fix manually connecting to a url when the username or password has a space in it

* Update CommonClient.py

* Update CommonClient.py
2025-10-13 18:55:44 +02:00
Exempt-Medic
30cedb13f3 Core: Limit ItemLink Name to 16 Characters (#4318) 2025-10-13 18:32:53 +02:00
Seldom
0c1ecf7297 Terraria: Remove /apstart from docs (#5537) 2025-10-13 18:06:25 +02:00
black-sliver
5390561b58 MultiServer: Fix breaking weakrefs for SetNotify (#5539) 2025-10-12 21:46:16 +02:00
threeandthreee
bb457b0f73 SNI Client: fix that it isnt using host.yaml settings (#5533) 2025-10-11 11:16:47 +02:00
threeandthreee
6276ccf415 LADX: move client out of root (#4226)
* init

* Revert "init"

This reverts commit bba6b7a306.

* put it back but clean

* pass args

* windows stuff

* delete old exe

this seems like it?

* use marin icon in launcher

* use LauncherComponents.launch
2025-10-10 17:56:15 +02:00
Mysteryem
d3588a057c Tests: gc.freeze() by default in the test\benchmark\locations.py (#5055)
Without `gc.freeze()` and `gc.unfreeze()` afterward, the `gc.collect()`
call within each benchmark often takes much longer than all 100_000
iterations of the location access rule, making it difficult to benchmark
all but the slowest of access rules.

This change enables using `gc.freeze()` by default.
2025-10-10 17:19:52 +02:00
Katelyn Gigante
30ce74d6d5 core: Add host.yaml setting to make !countdown configurable (#5465)
* core:  Add host.yaml setting to make !countdown configurable

* Store /option changes to countdown_mode in save file

* Wording changes in host.yaml

* Use .get

* Fix validation for /option command
2025-10-10 15:02:56 +02:00
NewSoupVi
ff59b86335 Docs: More apworld manifest documentation (#5477)
* Expand apworld specification manifest part

* clarity

* expand example

* clarify

* correct

* Correct

* elaborate on what version is

* Add where the apworlds are output

* authors & update versions

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md
2025-10-09 20:23:21 +02:00
Fabian Dill
6bf3067a39 Merge branch 'main' into setup_more_apworld 2025-08-02 05:41:59 +02:00
Fabian Dill
8d81513842 Merge branch 'main' into setup_more_apworld 2025-05-23 06:58:54 +02:00
Fabian Dill
c27be54a4c Merge branch 'main' into setup_more_apworld 2025-05-22 01:01:43 +02:00
Fabian Dill
a6316e9991 add back Lufia II AC 2025-05-20 13:41:50 +02:00
Fabian Dill
5130eba886 setup: convert a bunch of worlds to apworld 2025-05-19 14:32:28 +02:00
105 changed files with 1155 additions and 411 deletions

View File

@@ -9,21 +9,24 @@ on:
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch: workflow_dispatch:
env: env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # 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. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation permissions: # permissions required for attestation
@@ -139,9 +142,9 @@ 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/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c 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 wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c 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

View File

@@ -11,9 +11,10 @@ env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # 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. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation permissions: # permissions required for attestation
@@ -127,9 +128,9 @@ 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/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c 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 wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c 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

View File

@@ -59,7 +59,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-subtests pytest-xdist pip install -r ci-requirements.txt
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests - name: Unittests

View File

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

View File

@@ -135,6 +135,7 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
__slots__ = ( __slots__ = (
"__weakref__",
"version", "version",
"auth", "auth",
"team", "team",
@@ -216,6 +217,7 @@ class Context:
"release_mode": str, "release_mode": str,
"remaining_mode": str, "remaining_mode": str,
"collect_mode": str, "collect_mode": str,
"countdown_mode": str,
"item_cheat": bool, "item_cheat": bool,
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
@@ -245,8 +247,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
log_network: bool = False, logger: logging.Logger = logging.getLogger()): compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger self.logger = logger
super(Context, self).__init__() super(Context, self).__init__()
self.slot_info = {} self.slot_info = {}
@@ -279,6 +281,7 @@ class Context:
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat self.item_cheat = item_cheat
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[ self.client_activity_timers: typing.Dict[
@@ -664,6 +667,7 @@ class Context:
"server_password": self.server_password, "password": self.password, "server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode, "release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility} "item_cheat": self.item_cheat, "compatibility": self.compatibility}
} }
@@ -698,6 +702,7 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"] self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"] self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"] self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"] self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"] self.compatibility = savedata["game_options"]["compatibility"]
@@ -1195,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
found = location_id in ctx.location_checks[team, finding_player] found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
if found: if found:
status = HintStatus.HINT_FOUND hint_status = HintStatus.HINT_FOUND
elif status is None: elif hint_status is None:
if item_flags & ItemClassification.trap: if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID hint_status = HintStatus.HINT_AVOID
else: else:
status = HintStatus.HINT_PRIORITY hint_status = HintStatus.HINT_PRIORITY
hints.append( hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status) Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
) )
return hints return hints
@@ -1529,6 +1535,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect") " You can ask the server admin for a /collect")
return False return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
@@ -2489,6 +2512,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"): elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str): def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"): elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"} valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else []) valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2576,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion auto-enabled: !collect is available and automatically triggered on goal completion
''') ''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\ choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s) Select !remaining Accessibility. (default: %(default)s)
@@ -2641,7 +2676,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.remaining_mode, args.countdown_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network) args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata data_filename = args.multidata

View File

@@ -1474,8 +1474,10 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options) super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links: if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.") raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
f"You have more than one link named '{link['name']}'.")
existing_links.add(link["name"]) existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import concurrent.futures
import json import json
import typing import typing
import builtins import builtins
@@ -477,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)): self.options_module.PlandoItem, self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -1138,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str): if isinstance(obj, str):
return False return False
return isinstance(obj, typing.Iterable) return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown

View File

@@ -1,6 +1,7 @@
import base64 import base64
import os import os
import socket import socket
import typing
import uuid import uuid
from flask import Flask from flask import Flask
@@ -61,20 +62,21 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value): def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value): def to_url(value: uuid.UUID) -> str:
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') 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: str) -> uuid.UUID:
return to_python(value) return to_python(value)
def to_url(self, value): def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
return to_url(value) return to_url(value)
@@ -84,7 +86,7 @@ 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() -> None:
"""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 import importlib

View File

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

View File

@@ -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, mystery_argparse from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__, restricted_dumps from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
@@ -33,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)), "server_password": str(options_source.get("server_password", None)),
} }
@@ -106,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
else: else:
try: try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int) meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
except BaseException as e: except BaseException as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
@@ -117,7 +118,7 @@ def start_generation(options: dict[str, 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: dict[str, Any] | None = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
if meta is None: if meta is None:
meta = {} meta = {}
@@ -136,7 +137,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse() args = mystery_argparse([]) # Just to set up the Namespace with defaults
args.multi = playercount args.multi = playercount
args.seed = seed args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
@@ -171,11 +172,12 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
ERmain(args, seed, baked_server_options=meta["server_options"]) ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task) thread = thread_pool.submit(task)
try: try:
return thread.result(app.config["JOB_TIME"]) return thread.result(timeout)
except concurrent.futures.TimeoutError as e: except concurrent.futures.TimeoutError as e:
if sid: if sid:
with db_session: with db_session:
@@ -188,6 +190,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
format_exception(e)) format_exception(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -199,6 +204,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>') @app.route('/wait/<suuid:seed>')

90
WebHostLib/markdown.py Normal file
View File

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

View File

@@ -9,6 +9,7 @@ from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted from Utils import title_sorted
@@ -27,49 +28,6 @@ def get_visible_worlds() -> dict[str, type(World)]:
return worlds 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):
@@ -91,10 +49,9 @@ def game_info(game, lang):
theme = get_world_theme(game) theme = get_world_theme(game)
secure_game_name = secure_filename(game) secure_game_name = secure_filename(game)
lang = secure_filename(lang) lang = secure_filename(lang)
document = render_markdown(os.path.join( file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
app.static_folder, "generated", "docs", file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
secure_game_name, f"{lang}_{secure_game_name}.md" document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
))
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title=f"{game} Guide", title=f"{game} Guide",
@@ -119,10 +76,9 @@ def tutorial(game: str, file: str):
theme = get_world_theme(game) theme = get_world_theme(game)
secure_game_name = secure_filename(game) secure_game_name = secure_filename(game)
file = secure_filename(file) file = secure_filename(file)
document = render_markdown(os.path.join( file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
app.static_folder, "generated", "docs", file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
secure_game_name, file+".md" document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
))
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title=f"{game} Guide", title=f"{game} Guide",

View File

@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines() lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:])) text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={ return publish_parts(text, writer='html', settings=None, settings_overrides={
'raw_enable': False, 'raw_enable': False,
'file_insertion_enabled': False, 'file_insertion_enabled': False,
'output_encoding': 'unicode' 'output_encoding': 'unicode'

View File

@@ -4,9 +4,10 @@ pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2 waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17 Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2
setproctitle>=1.3.5 setproctitle>=1.3.5
mistune>=3.1.3 mistune>=3.1.3
docutils>=0.22.2

3
ci-requirements.txt Normal file
View File

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

View File

@@ -8,3 +8,7 @@ SELFLAUNCH: false
# Host Address. This is the address encoded into the patch that will be used for client auto-connect. # 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. # Set as your local IP (192.168.x.x) to serve over LAN.
HOST_ADDRESS: localhost HOST_ADDRESS: localhost
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
# the proprietary assets in WebHostLib
#ASSET_RIGHTS: false

View File

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

View File

@@ -647,6 +647,16 @@ class Version(NamedTuple):
build: int build: int
``` ```
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
```
"version": {
"class": "Version",
"build": X,
"major": Y,
"minor": Z
}
```
### SlotType ### SlotType
An enum representing the nature of a slot. An enum representing the nature of a slot.

View File

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

11
kvui.py
View File

@@ -34,6 +34,17 @@ from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch") Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0") Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
from kivy.core.audio import SoundLoader
for classobj in SoundLoader._classes:
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
classobj.extensions()
from kivymd.uix.divider import MDDivider from kivymd.uix.divider import MDDivider
from kivy.core.window import Window from kivy.core.window import Window
from kivy.core.clipboard import Clipboard from kivy.core.clipboard import Clipboard

16
ruff.toml Normal file
View File

@@ -0,0 +1,16 @@
line-length = 120
indent-width = 4
target-version = "py311"
[lint]
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
ignore = [
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
"C901", # Author disagrees with limiting branch complexity
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
"PLC0415", # In AP, we consider local imports totally fine & necessary
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
"PLC1901", # This is just not equivalent
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
]

View File

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

View File

@@ -63,18 +63,11 @@ from Cython.Build import cythonize
non_apworlds: set[str] = { non_apworlds: set[str] = {
"A Link to the Past", "Archipelago", # needs a way to specify load order
"Adventure", "Final Fantasy", # loads json files badly
"Archipelago", "Lufia II Ancient Cave", # loads basepatch badly
"Lufia II Ancient Cave", "Ocarina of Time", # has executables in folder
"Meritous", "Raft", # loads json files badly
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Sudoku",
"Super Mario 64",
"VVVVVV",
"Wargroove",
} }
@@ -146,7 +139,16 @@ def download_SNI() -> None:
signtool: str | None = None signtool: str | None = None
try: try:
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response: import socket
sign_host, sign_port = "192.168.206.4", 12345
# check if the sign_host is on a local network
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((sign_host, sign_port))
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
raise ConnectionError() # would go through default route
# configure signtool
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
html = response.read() html = response.read()
if b"status=OK\n" in html: if b"status=OK\n" in html:
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
@@ -381,7 +383,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"): if os.path.isfile(world_directory / "archipelago.json"):
manifest = json.load(open(world_directory / "archipelago.json")) with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
"""Check world sources' manifest files"""
import json
import unittest
from pathlib import Path
from typing import Any, ClassVar
import test
from Utils import home_path, local_path
from worlds.AutoWorld import AutoWorldRegister
from ..param import classvar_matrix
test_path = Path(test.__file__).parent
worlds_paths = [
Path(local_path("worlds")),
Path(local_path("custom_worlds")),
Path(home_path("worlds")),
Path(home_path("custom_worlds")),
]
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
source_world_names = [
k
for k, v in AutoWorldRegister.world_types.items()
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
]
def get_source_world_manifest_path(game: str) -> Path | None:
"""Get path of archipelago.json in the world's root folder from game name."""
# TODO: add a feature to AutoWorld that makes this less annoying
world_type = AutoWorldRegister.world_types[game]
world_type_path = Path(world_type.__file__)
for worlds_path in worlds_paths:
if world_type_path.is_relative_to(worlds_path):
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
manifest_path = world_root / "archipelago.json"
return manifest_path if manifest_path.exists() else None
assert False, f"{world_type_path} not found in any worlds path"
# TODO: remove the filter once manifests are mandatory.
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
class TestWorldManifest(unittest.TestCase):
game: ClassVar[str]
manifest: ClassVar[dict[str, Any]]
@classmethod
def setUpClass(cls) -> None:
world_type = AutoWorldRegister.world_types[cls.game]
assert world_type.game == cls.game
manifest_path = get_source_world_manifest_path(cls.game)
assert manifest_path # make mypy happy
with manifest_path.open("r", encoding="utf-8") as f:
cls.manifest = json.load(f)
def test_game(self) -> None:
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
self.assertIn(
"game",
self.manifest,
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
)
self.assertEqual(
self.manifest["game"],
self.game,
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
)
def test_world_version(self) -> None:
"""Test that world_version matches the requirements in apworld specification.md"""
if "world_version" in self.manifest:
world_version: str = self.manifest["world_version"]
self.assertIsInstance(
world_version,
str,
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
)
parts = world_version.split(".")
self.assertEqual(
len(parts),
3,
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
)
for part in parts:
self.assertTrue(
part.isdigit(),
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
)
def test_no_container_version(self) -> None:
self.assertNotIn(
"version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
)
self.assertNotIn(
"compatible_version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
)

View File

@@ -3,6 +3,7 @@
# Run with `python test/hosting` instead, # Run with `python test/hosting` instead,
import logging import logging
import traceback import traceback
from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from time import sleep from time import sleep
from typing import Any from typing import Any
@@ -11,7 +12,7 @@ from test.hosting.client import Client
from test.hosting.generate import generate_local from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata) stop_autogen, stop_autohost, upload_multidata, generate_remote)
from test.hosting.world import copy as copy_world, delete as delete_world from test.hosting.world import copy as copy_world, delete as delete_world
failure = False failure = False
@@ -56,35 +57,62 @@ else:
if __name__ == "__main__": if __name__ == "__main__":
import sys
import warnings import warnings
warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", DeprecationWarning)
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = [] p1_games: list[str] = []
data_paths = [] data_paths: list[Path | None] = []
rooms = [] rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "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)} offline")
multidata = generate_local(games, tempdir) multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata) data_paths.append(multidata)
p1_games.append(games[0])
finally: finally:
delete_world("Temp World") delete_world("Temp World")
webapp = get_app(tempdir) webapp = get_app(tempdir)
webhost_client = webapp.test_client() webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1): for n, multidata in enumerate(data_paths, 1):
assert multidata
seed = upload_multidata(webhost_client, multidata) seed = upload_multidata(webhost_client, multidata)
print(f"Uploaded [{n}] {multidata} as {seed}\n")
room = create_room(webhost_client, seed) room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n") print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room)
# Generate 1 extra game on WebHost
from WebHostLib.autolauncher import autogen
for n, games in enumerate(multis[:1], len(multis) + 1):
multis.append(games)
try:
print(f"Generating [{n}] {', '.join(games)} online")
autogen(webapp.config)
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
seed = generate_remote(webhost_client, games)
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
finally:
stop_autogen()
data_paths.append(None) # WebHost-only
room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room) rooms.append(room)
print("Starting autohost") print("Starting autohost")
@@ -96,31 +124,10 @@ if __name__ == "__main__":
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games) involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3): for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
prev_host_adr = host.address prev_host_adr = host.address
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
@@ -134,6 +141,7 @@ if __name__ == "__main__":
autohost(webapp.config) # this will spin the room right up again autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below # if saving failed, the next iteration will fail below
sleep(2) # work around issue #5571
# verify server shut down # verify server shut down
try: try:
@@ -156,6 +164,31 @@ if __name__ == "__main__":
"customserver did not load or save correctly during/after " "customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit")) + ("Ctrl+C" if collected_items == 2 else "/exit"))
if not multidata:
continue # games rolled on WebHost can not be tested against MultiServer
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datapackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
# compare customserver to MultiServer # compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages, expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer") "customserver datapackage differs from MultiServer")
@@ -176,10 +209,12 @@ if __name__ == "__main__":
print(f"Restoring multidata for {room}") print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data) set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2, assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver") "Save was destroyed during exception in customserver")
print("Save file is not busted 🥳") print("Save file is not busted 🥳")
sleep(2) # work around issue #5571
finally: finally:
print("Stopping autohost") print("Stopping autohost")

View File

@@ -1,6 +1,10 @@
import io
import json
import re import re
import time
import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Iterable, Optional, cast
from WebHostLib import to_python from WebHostLib import to_python
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
"get_app", "get_app",
"generate_remote",
"upload_multidata", "upload_multidata",
"create_room", "create_room",
"start_room", "start_room",
@@ -17,6 +22,7 @@ __all__ = [
"set_room_timeout", "set_room_timeout",
"get_multidata_for_room", "get_multidata_for_room",
"set_multidata_for_room", "set_multidata_for_room",
"stop_autogen",
"stop_autohost", "stop_autohost",
] ]
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
"TESTING": True, "TESTING": True,
"HOST_ADDRESS": "localhost", "HOST_ADDRESS": "localhost",
"HOSTERS": 1, "HOSTERS": 1,
"GENERATORS": 1,
"JOB_THRESHOLD": 1,
}) })
return get_app() return get_app()
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
data = io.BytesIO()
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for n, game in enumerate(games, 1):
name = f"{n}.yaml"
zip_file.writestr(name, json.dumps({
"name": f"Player{n}",
"game": game,
game: {},
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
}))
data.seek(0)
response = app_client.post("/generate", content_type="multipart/form-data", data={
"file": (data, "yamls.zip"),
})
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
assert "Location" in response.headers, f"Starting gen failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
for attempt in range(10):
response = app_client.get(location)
if "Location" in response.headers:
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
return location[6:]
time.sleep(1)
raise TimeoutError("WebHost gen did not finish")
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={ response = app_client.post("/uploads", data={
"file": multidata.open("rb"), "file": multidata.open("rb"),
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
room.seed.multidata = data room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None: def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
import os import os
import signal import signal
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
stop() stop()
proc: multiprocessing.process.BaseProcess proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
# and ungraceful may not save the game
if proc.pid == os.getpid():
continue
if graceful and proc.pid: if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else: else:
proc.kill() proc.kill()
try: try:
proc.join(30) try:
proc.join(30)
except TimeoutError:
raise
except KeyboardInterrupt:
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
proc.join(30)
except TimeoutError: except TimeoutError:
proc.kill() proc.kill()
proc.join() proc.join()
def stop_autogen(graceful: bool = True) -> None:
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
_stop_webhost_mp("SpawnPoolWorker-", graceful)
def stop_autohost(graceful: bool = True) -> None:
_stop_webhost_mp("MultiHoster", graceful)

View File

@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None: def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name from Utils import get_file_safe_name
from worlds import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
assert dst not in _new_worlds, "World already created" assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape if '"' in dst or "\\" in dst: # easier to reject than to escape

View File

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

View File

@@ -0,0 +1,78 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from mistune import HTMLRenderer, Markdown
from WebHostLib.markdown import ImgUrlRewriteInlineParser, render_markdown
class ImgUrlRewriteTest(unittest.TestCase):
markdown: Markdown
base_url = "/static/generated/docs/some_game"
def setUp(self) -> None:
self.markdown = Markdown(
renderer=HTMLRenderer(escape=False),
inline=ImgUrlRewriteInlineParser(self.base_url),
)
def test_relative_img_rewrite(self) -> None:
html = self.markdown("![Image](image.png)")
self.assertIn(f'src="{self.base_url}/image.png"', html)
def test_absolute_img_no_rewrite(self) -> None:
html = self.markdown("![Image](/image.png)")
self.assertIn(f'src="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_remote_img_no_rewrite(self) -> None:
html = self.markdown("![Image](https://example.com/image.png)")
self.assertIn(f'src="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_link_no_rewrite(self) -> None:
# The parser is only supposed to update images, not links.
html = self.markdown("[Link](image.png)")
self.assertIn(f'href="image.png"', html)
self.assertNotIn(self.base_url, html)
def test_absolute_link_no_rewrite(self) -> None:
html = self.markdown("[Link](/image.png)")
self.assertIn(f'href="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_auto_link_no_rewrite(self) -> None:
html = self.markdown("<https://example.com/image.png>")
self.assertIn(f'href="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_img_to_other_game(self) -> None:
html = self.markdown("![Image](../../generic/docs/image.png)")
self.assertIn(f'src="{self.base_url}/../Archipelago/image.png"', html)
class RenderMarkdownTest(unittest.TestCase):
"""Tests that render_markdown does the right thing."""
base_url = "/static/generated/docs/some_game"
def test_relative_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name, self.base_url)
self.assertIn(f'src="{self.base_url}/image.png"', html)
finally:
os.unlink(f.name)
def test_no_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name)
self.assertIn(f'src="image.png"', html)
self.assertNotIn(self.base_url, html)
finally:
os.unlink(f.name)

View File

@@ -217,8 +217,6 @@ components: List[Component] = [
description="Install an APWorld to play games not included with Archipelago by default."), description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."), description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time # Ocarina of Time
Component('OoT Client', 'OoTClient', Component('OoT Client', 'OoTClient',
@@ -273,7 +271,8 @@ if not is_frozen():
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name) world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")): if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
manifest = json.load(open(os.path.join(world_directory, "archipelago.json"))) with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

@@ -122,7 +122,8 @@ for world_source in world_sources:
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path): for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames: for file in filenames:
if file.endswith("archipelago.json"): if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r")) with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
break break
if manifest: if manifest:
break break

View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import time
import Utils import Utils
import websockets import websockets
import functools import functools
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
if not ctx.is_proxy_connected(): if not ctx.is_proxy_connected():
break break
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
msg["data"]["time"] = time.time()
await ctx.send_msgs([msg]) await ctx.send_msgs([msg])
except Exception as e: except Exception as e:

View File

@@ -243,7 +243,7 @@ guaranteed_first_acts = [
"Time Rift - Mafia of Cooks", "Time Rift - Mafia of Cooks",
"Time Rift - Dead Bird Studio", "Time Rift - Dead Bird Studio",
"Time Rift - Sleepy Subcon", "Time Rift - Sleepy Subcon",
"Time Rift - Alpine Skyline" "Time Rift - Alpine Skyline",
"Time Rift - Tour", "Time Rift - Tour",
"Time Rift - Rumbi Factory", "Time Rift - Rumbi Factory",
] ]

View File

@@ -88,9 +88,8 @@ You only have to do these steps once.
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -88,9 +88,8 @@ Sólo hay que seguir estos pasos una vez.
1. Comienza en la pantalla del menú principal de RetroArch. 1. Comienza en la pantalla del menú principal de RetroArch.
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON. 2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto, 3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
el Puerto de comandos de red. el Puerto de comandos de red. \
![Captura de pantalla del ajuste Comandos de red](../../generic/docs/retroarch-network-commands-en.png)
![Captura de pantalla del ajuste Comandos de red](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES / 4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
SFC (bsnes-mercury Performance)". SFC (bsnes-mercury Performance)".

View File

@@ -89,9 +89,8 @@ Vous n'avez qu'à faire ces étapes qu'une fois.
1. Entrez dans le menu principal RetroArch 1. Entrez dans le menu principal RetroArch
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON. 2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le 3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
Port des commandes réseau à 555355. Port des commandes réseau à 555355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-fr.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-fr.png)
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et 4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
sélectionnez le. sélectionnez le.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -13,7 +13,7 @@ All scraps or any collectable item on the ground (except from Loot Crates) and i
Beating the evil train from Hell named "Charles". Beating the evil train from Hell named "Charles".
## How is the game managed in Nightmare mode? ## How is the game managed in Nightmare mode?
At death, the player has to restart a brand-new game, giving him the choice to stay under the Nightmare mode or continuing with the Normal mode if considered too hard. At death, the player has to restart a brand-new game, giving them the choice to stay under the Nightmare Mode or continuing with the Classic Mode if considered too hard.
In this case, all collected items will be redistributed in the inventory and the missions states will be kept. In this case, all collected items will be redistributed in the inventory and the missions states will be kept.
The Deathlink is not implemented yet. When this option will be available, a choice will be provided to: The Deathlink is not implemented yet. When this option will be available, a choice will be provided to:
* Disable the Deathlink * Disable the Deathlink

View File

@@ -3,14 +3,14 @@
## Où est la page d'options ? ## Où est la page d'options ?
La [page d'options du joueur pour ce jeu](../player-options) contient toutes les options pour configurer et exporter un fichier de configuration yaml. La [page d'options du joueur pour ce jeu](../player-options) contient toutes les options pour configurer et exporter un fichier de configuration yaml.
## Qu'est ce que la randomisation fait au jeu ? ## Qu'est-ce que la randomisation fait au jeu ?
Tous les débrits ou n'importe quel objet ramassable au sol (excepté les Caisses à Butin) et objets reçus par les missions de PNJs sont considérés comme emplacements à vérifier. Tous les débris ou n'importe quel objet ramassable au sol (excepté les Caisses à Butin) et objets reçus par les missions de PNJs sont considérés comme emplacements à vérifier.
## Quel est le but de Choo-Choo Charles lorsqu'il est randomisé ? ## Quel est le but de Choo-Choo Charles lorsqu'il est randomisé ?
Vaincre le train démoniaque de l'Enfer nommé "Charles". Vaincre le train démoniaque de l'Enfer nommé "Charles".
## Comment le jeu est-il géré en mode Nightmare ? ## Comment le jeu est-il géré en Mode Cauchemar ?
À sa mort, le joueur doit relancer une toute nouvelle partie, lui donnant la possisilité de rester en mode Nightmare ou de poursuivre la partie en mode Normal s'il considère la partie trop difficile. À sa mort, le joueur doit relancer une toute nouvelle partie, lui donnant la possibilité de rester en Mode Cauchemar ou de poursuivre la partie en Mode Classique s'il considère la partie trop difficile.
Dans ce cas, tous les objets collectés seront redistribués dans l'inventaire et les états des missions seront conservés. Dans ce cas, tous les objets collectés seront redistribués dans l'inventaire et les états des missions seront conservés.
Le Deathlink n'est pas implémenté pour l'instant. Lorsque cette option sera disponible, un choix sera fourni pour : Le Deathlink n'est pas implémenté pour l'instant. Lorsque cette option sera disponible, un choix sera fourni pour :
* Désactiver le Deathlink * Désactiver le Deathlink
@@ -18,7 +18,7 @@ Le Deathlink n'est pas implémenté pour l'instant. Lorsque cette option sera di
* Activer le Deathlink strict avec suppression de la sauvegarde lorsqu'un évènement Deathlink est reçu * Activer le Deathlink strict avec suppression de la sauvegarde lorsqu'un évènement Deathlink est reçu
## À quoi ressemble un objet d'un autre monde dans Choo-Choo Charles ? ## À quoi ressemble un objet d'un autre monde dans Choo-Choo Charles ?
Les apparances des objets sont conservés. Les apparences des objets sont conservées.
Tout indice qui ne peut pas être représenté normalement dans le jeu est remplacé par l'Easter Egg "DeadDuck" miniaturisé qui peut être vu en dehors des limites murales physiques du jeu original. Tout indice qui ne peut pas être représenté normalement dans le jeu est remplacé par l'Easter Egg "DeadDuck" miniaturisé qui peut être vu en dehors des limites murales physiques du jeu original.
## Comment le joueur est-il informé par une transmission d'objet et des indices ? ## Comment le joueur est-il informé par une transmission d'objet et des indices ?
@@ -29,7 +29,7 @@ La même méthode est utilisée pour les indices.
Non, ceci est un travail en cours. Non, ceci est un travail en cours.
Les options suivantes seront possibles une fois les implémentations disponibles : Les options suivantes seront possibles une fois les implémentations disponibles :
À n'importe quel moment, le joueur peu appuyer sur l'une des touches suivantes pour afficher la console dans le jeu : À n'importe quel moment, le joueur peut appuyer sur l'une des touches suivantes pour afficher la console dans le jeu :
* "~" (qwerty) * "~" (qwerty)
* "²" (azerty) * "²" (azerty)
* "F10" * "F10"

View File

@@ -27,7 +27,7 @@ The [Player Options page](/games/Choo-Choo%20Charles/player-options) allows to c
Before playing, it is highly recommended to check out the **[Known Issues](setup_en#known-issues)** section Before playing, it is highly recommended to check out the **[Known Issues](setup_en#known-issues)** section
* The game console must be opened to type Archipelago commands, press "F10" key or "`" (or "~") key in querty ("²" key in azerty) * The game console must be opened to type Archipelago commands, press "F10" key or "`" (or "~") key in querty ("²" key in azerty)
* Type ``/connect <IP> <PlayerName>`` with \<IP\> and \<PlayerName\> found on the hosting Archipelago web page in the form ``archipelago.gg:XXXXX`` and ``CCCharles`` * Type ``/connect <IP> <PlayerName>`` with \<IP\> and \<PlayerName\> found on the hosting Archipelago web page in the form ``archipelago.gg:XXXXX`` and ``CCCharles``
* Disconnection is automatic at game closure but can be manually done with ``/disconnect`` * Disconnection is automatic at game closure, but can be manually done with ``/disconnect``
## Hosting a MultiWorld or Single-Player Game ## Hosting a MultiWorld or Single-Player Game
See the [Mod Download](setup_en#mod-download) section to get the **cccharles.apworld** file. See the [Mod Download](setup_en#mod-download) section to get the **cccharles.apworld** file.

View File

@@ -4,7 +4,7 @@ Cette page est un guide simplifié de la [page du Mod Randomiseur Multiworld de
## Exigences et Logiciels Nécessaires ## Exigences et Logiciels Nécessaires
* Un ordinateur utilisant Windows (le Mod n'est pas utilisable sous Linux ou Mac) * Un ordinateur utilisant Windows (le Mod n'est pas utilisable sous Linux ou Mac)
* [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) * [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
* Une copie légale du jeu original Choo-Choo Charles (peut être trouvé sur [Steam](https://store.steampowered.com/app/1766740/ChooChoo_Charles/) * Une copie légale du jeu original Choo-Choo Charles (peut être trouvé sur [Steam](https://store.steampowered.com/app/1766740/ChooChoo_Charles/))
## Installation du Mod pour jouer ## Installation du Mod pour jouer
### Téléchargement du Mod ### Téléchargement du Mod
@@ -27,7 +27,7 @@ La [page d'Options Joueur](/games/Choo-Choo%20Charles/player-options) permet de
Avant de jouer, il est fortement recommandé de consulter la section **[Problèmes Connus](setup_fr#probl%C3%A8mes-connus)**. Avant de jouer, il est fortement recommandé de consulter la section **[Problèmes Connus](setup_fr#probl%C3%A8mes-connus)**.
* La console du jeu doit être ouverte pour taper des commandes Archipelago, appuyer sur la touche "F10" ou "`" (ou "~") en querty (touche "²" en azerty) * La console du jeu doit être ouverte pour taper des commandes Archipelago, appuyer sur la touche "F10" ou "`" (ou "~") en querty (touche "²" en azerty)
* Taper ``/connect <IP> <NomDuJoueur>`` avec \<IP\> et \<NomDuJoueur\> trouvés sur la page web d'hébergement Archipelago sous la forme ``archipelago.gg:XXXXX`` et ``CCCharles`` * Taper ``/connect <IP> <NomDuJoueur>`` avec \<IP\> et \<NomDuJoueur\> trouvés sur la page web d'hébergement Archipelago sous la forme ``archipelago.gg:XXXXX`` et ``CCCharles``
* La déconnexion est automatique à la fermeture du jeu mais peut être faite manuellement avec ``/disconnect`` * La déconnexion est automatique à la fermeture du jeu, mais peut être faite manuellement avec ``/disconnect``
## Héberger une partie MultiWorld ou un Seul Joueur ## Héberger une partie MultiWorld ou un Seul Joueur
Voir la section [Téléchargement du Mod](setup_fr#téléchargement-du-mod) pour récupérer le fichier **cccharles.apworld**. Voir la section [Téléchargement du Mod](setup_fr#téléchargement-du-mod) pour récupérer le fichier **cccharles.apworld**.
@@ -39,7 +39,7 @@ Suivre ces étapes pour héberger une session multijoueur à distance ou locale
2. Placer le **CCCharles.yaml** dans **Archipelago/Players/** avec le YAML de chaque joueur à héberger 2. Placer le **CCCharles.yaml** dans **Archipelago/Players/** avec le YAML de chaque joueur à héberger
3. Exécuter le lanceur Archipelago et cliquer sur "Generate" pour configurer une partie avec les YAML dans **Archipelago/output/** 3. Exécuter le lanceur Archipelago et cliquer sur "Generate" pour configurer une partie avec les YAML dans **Archipelago/output/**
4. Pour une session multijoueur, aller à la [page Archipelago HOST GAME](https://archipelago.gg/uploads) 4. Pour une session multijoueur, aller à la [page Archipelago HOST GAME](https://archipelago.gg/uploads)
5. Cliquer sur "Upload File" et selectionner le **AP_\<seed\>.zip** généré dans **Archipelago/output/** 5. Cliquer sur "Upload File" et sélectionner le **AP_\<seed\>.zip** généré dans **Archipelago/output/**
6. Envoyer la page de la partie générée à chaque joueur 6. Envoyer la page de la partie générée à chaque joueur
Pour une session locale à un seul joueur, cliquer sur "Host" dans le lanceur Archipelago en utilisant **AP_\<seed\>.zip** généré dans **Archipelago/output/** Pour une session locale à un seul joueur, cliquer sur "Host" dans le lanceur Archipelago en utilisant **AP_\<seed\>.zip** généré dans **Archipelago/output/**

View File

@@ -20,6 +20,7 @@ class CivVIBoostData:
Prereq: List[str] Prereq: List[str]
PrereqRequiredCount: int PrereqRequiredCount: int
Classification: str Classification: str
EraRequired: bool = False
class GoodyHutRewardData(TypedDict): class GoodyHutRewardData(TypedDict):

View File

@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
location = CivVILocationData( location = CivVILocationData(
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
) )
era_locations["ERA_ANCIENT"][boost.Type] = location # If EraRequired is True, place the boost in its actual era
# Otherwise, place it in ERA_ANCIENT for early access
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
era_locations[target_era][boost.Type] = location
id_base += 1 id_base += 1
return era_locations return era_locations

View File

@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_SQUARE_RIGGING", "BOOST_TECH_SQUARE_RIGGING",
"ERA_RENAISSANCE", "ERA_RENAISSANCE",
["TECH_GUNPOWDER"], ["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_BALLISTICS", "BOOST_TECH_BALLISTICS",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"], ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
2, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_MILITARY_SCIENCE", "BOOST_TECH_MILITARY_SCIENCE",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_STIRRUPS"], ["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_REPLACEABLE_PARTS", "BOOST_TECH_REPLACEABLE_PARTS",
"ERA_MODERN", "ERA_MODERN",
["TECH_MILITARY_SCIENCE"], ["TECH_MILITARY_SCIENCE", "TECH_MINING"],
1, 2,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_ADVANCED_FLIGHT", "BOOST_TECH_ADVANCED_FLIGHT",
"ERA_ATOMIC", "ERA_ATOMIC",
["TECH_FLIGHT"], ["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_COMPOSITES", "BOOST_TECH_COMPOSITES",
"ERA_INFORMATION", "ERA_INFORMATION",
["TECH_COMBUSTION"], ["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
"TECH_ELECTRICITY", "TECH_ELECTRICITY",
"TECH_NUCLEAR_FISSION", "TECH_NUCLEAR_FISSION",
], ],
1, 4,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_FEUDALISM", "BOOST_CIVIC_FEUDALISM",
"ERA_MEDIEVAL", "ERA_CLASSICAL",
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CIVIL_SERVICE", "BOOST_CIVIC_CIVIL_SERVICE",
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_MERCENARIES", "BOOST_CIVIC_MERCENARIES",
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CONSERVATION", "BOOST_CIVIC_CONSERVATION",
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
["TECH_ROCKETRY"], ["TECH_ROCKETRY"],
1, 1,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_GLOBALIZATION", "BOOST_CIVIC_GLOBALIZATION",

View File

@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
if "BOOST" in location.name: if "BOOST" in location.name:
found_locations += 1 found_locations += 1
self.assertEqual(found_locations, 0) self.assertEqual(found_locations, 0)
class TestBoostsanityEraRequired(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "none",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_boosts_not_accessible_early(self) -> None:
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
# Regular boosts without EraRequired should be accessible
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
# Collect items to reach Classical era
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
"Irrigation", "Sailing", "Animal Husbandry",
"State Workforce", "Foreign Trade"])
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect more items to reach Industrial era
self.collect_all_but(["TECH_ROCKETRY"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "eras_and_districts",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_with_progressive_eras(self) -> None:
# Collect all items except Progressive Era
self.collect_all_but(["Progressive Era"])
# Even with all other items, era-required boosts should not be accessible
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect enough Progressive Era items to reach Classical (needs 2)
self.collect(self.get_item_by_name("Progressive Era"))
self.collect(self.get_item_by_name("Progressive Era"))
# BOOST_CIVIC_FEUDALISM should now be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect 3 more Progressive Era items to reach Industrial
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))

View File

@@ -609,8 +609,8 @@ class CV64PatchExtensions(APPatchExtension):
# Shimmy speed increase hack # Shimmy speed increase hack
if options["increase_shimmy_speed"]: if options["increase_shimmy_speed"]:
rom_data.write_int32(0x97EB4, 0x803FE9F0) rom_data.write_int32(0x97EB4, 0x803FEA20)
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier) rom_data.write_int32s(0xBFEA20, patches.shimmy_speed_modifier)
# Disable landing fall damage # Disable landing fall damage
if options["fall_guard"]: if options["fall_guard"]:

View File

@@ -110,7 +110,7 @@ def get_item_counts(world: "CVCotMWorld") -> Dict[ItemClassification, Dict[str,
# If Halve DSS Cards Placed is on, determine which cards we will exclude here. # If Halve DSS Cards Placed is on, determine which cards we will exclude here.
if world.options.halve_dss_cards_placed: if world.options.halve_dss_cards_placed:
excluded_cards = list(ACTION_CARDS.union(ATTRIBUTE_CARDS)) excluded_cards = sorted(ACTION_CARDS.union(ATTRIBUTE_CARDS))
has_freeze_action = False has_freeze_action = False
has_freeze_attr = False has_freeze_attr = False

View File

@@ -111,9 +111,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -2,6 +2,7 @@ import csv
import enum import enum
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import reduce
from random import Random from random import Random
from typing import Dict, List, Set from typing import Dict, List, Set
@@ -61,7 +62,7 @@ def load_item_csv():
item_reader = csv.DictReader(file) item_reader = csv.DictReader(file)
for item in item_reader: for item in item_reader:
id = int(item["id"]) if item["id"] else None id = int(item["id"]) if item["id"] else None
classification = ItemClassification[item["classification"]] classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
groups = {Group[group] for group in item["groups"].split(",") if group} groups = {Group[group] for group in item["groups"].split(",") if group}
items.append(ItemData(id, item["name"], classification, groups)) items.append(ItemData(id, item["name"], classification, groups))
return items return items

View File

@@ -22,7 +22,7 @@ id,name,classification,groups
20,Wall Jump Pack,progression,"DLC,Freemium" 20,Wall Jump Pack,progression,"DLC,Freemium"
21,Health Bar Pack,useful,"DLC,Freemium" 21,Health Bar Pack,useful,"DLC,Freemium"
22,Parallax Pack,filler,"DLC,Freemium" 22,Parallax Pack,filler,"DLC,Freemium"
23,Harmless Plants Pack,progression,"DLC,Freemium" 23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
24,Death of Comedy Pack,progression,"DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium"
25,Canadian Dialog Pack,filler,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium"
26,DLC NPC Pack,progression,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium"
1 id name classification groups
22 20 Wall Jump Pack progression DLC,Freemium
23 21 Health Bar Pack useful DLC,Freemium
24 22 Parallax Pack filler DLC,Freemium
25 23 Harmless Plants Pack progression progression,trap DLC,Freemium
26 24 Death of Comedy Pack progression DLC,Freemium
27 25 Canadian Dialog Pack filler DLC,Freemium
28 26 DLC NPC Pack progression DLC,Freemium

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 493 KiB

View File

@@ -92,7 +92,7 @@ appropriate to your operating system, and extract the folder to a convenient loc
Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio". Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio".
![Factorio Download Options](/static/generated/docs/Factorio/factorio-download.png) ![Factorio Download Options](factorio-download.png)
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`. You Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`. You
will be asked to log in to your Factorio account using the same credentials you used on Factorio's website. After you will be asked to log in to your Factorio account using the same credentials you used on Factorio's website. After you
@@ -122,7 +122,7 @@ This allows you to host your own Factorio game.
Archipelago if you chose to include it during the installation process. Archipelago if you chose to include it during the installation process.
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter" 6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
![Factorio Client for Archipelago Connection Command](/static/generated/docs/Factorio/connect-to-ap-server.png) ![Factorio Client for Archipelago Connection Command](connect-to-ap-server.png)
7. Launch your Factorio Client 7. Launch your Factorio Client
8. Click on "Multiplayer" in the main menu 8. Click on "Multiplayer" in the main menu

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
rom_name_location = 0x07FFE3 rom_name_location = 0x07FFE3
player_name_location = 0x07BCC0
locations_array_start = 0x200 locations_array_start = 0x200
locations_array_length = 0x100 locations_array_length = 0x100
items_obtained = 0x03 items_obtained = 0x03
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
return True return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
auth_raw = (await bizhawk.read(
ctx.bizhawk_ctx,
[(player_name_location, 0x40, self.rom)]))[0]
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
async def game_watcher(self, ctx: "BizHawkClientContext") -> None: async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None: if ctx.server is None:
return return
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
write_list.append((location, [0], self.sram)) write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items: elif current_item_name in no_overworld_items:
if current_item_name == "Sigil": if current_item_name == "Sigil":
location = 0x28 location = 0x2B
else: else:
location = 0x12 location = 0x12
write_list.append((location, [1], self.sram)) write_list.append((location, [1], self.sram))

View File

@@ -253,5 +253,17 @@
"CubeBot": 529, "CubeBot": 529,
"Sarda": 525, "Sarda": 525,
"Fairy": 531, "Fairy": 531,
"Lefein": 527 "Lefein": 527,
"DeepDungeon32B_Chest144": 401,
"DeepDungeon30B_Chest145": 402,
"DeepDungeon29B_Chest146": 403,
"DeepDungeon29B_Chest147": 404,
"DeepDungeon40B_Chest186": 443,
"DeepDungeon38B_Chest188": 445,
"DeepDungeon36B_Chest189": 446,
"DeepDungeon33B_Chest190": 447,
"DeepDungeon40B_Chest191": 448,
"DeepDungeon41B_Chest192": 449,
"DeepDungeon34B_Chest193": 450,
"DeepDungeon39B_Chest194": 451
} }

View File

@@ -115,9 +115,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -123,10 +123,8 @@ Vous ne devez faire ces étapes qu'une fois. À noter que RetroArch 1.9.x ne fon
1. Entrez dans le menu principal de RetroArch. 1. Entrez dans le menu principal de RetroArch.
2. Allez dans Settings --> User Interface. Activez l'option "Show Advanced Settings". 2. Allez dans Settings --> User Interface. Activez l'option "Show Advanced Settings".
3. Allez dans Settings --> Network. Activez l'option "Network Commands", qui se trouve sous "Request Device 16". 3. Allez dans Settings --> Network. Activez l'option "Network Commands", qui se trouve sous "Request Device 16".
Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355. Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355. \
![Capture d'écran du menu Network Commands setting](../../generic/docs/retroarch-network-commands-fr.png)
![Capture d'écran du menu Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Allez dans le Menu Principal --> Online Updater --> Core Downloader. Trouvez et sélectionnez "Nintendo - SNES / SFC (bsnes-mercury 4. Allez dans le Menu Principal --> Online Updater --> Core Downloader. Trouvez et sélectionnez "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -1,4 +1,5 @@
from typing import NamedTuple, Union from typing import NamedTuple, Union
from typing_extensions import deprecated
import logging import logging
from BaseClasses import Item, Tutorial, ItemClassification from BaseClasses import Item, Tutorial, ItemClassification
@@ -49,7 +50,8 @@ class GenericWorld(World):
return Item(name, ItemClassification.filler, -1, self.player) return Item(name, ItemClassification.filler, -1, self.player)
raise InvalidItemError(name) raise InvalidItemError(name)
@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. "
"Use Options.PlandoItem(s) instead.")
class PlandoItem(NamedTuple): class PlandoItem(NamedTuple):
item: str item: str
location: str location: str

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,6 +5,8 @@
* A legal copy of Hollow Knight. * A legal copy of Hollow Knight.
* Steam, Gog, and Xbox Game Pass versions of the game are supported. * Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported. * Windows, Mac, and Linux (including Steam Deck) are supported.
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
## Installing the Archipelago Mod using Lumafly ## Installing the Archipelago Mod using Lumafly
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.

View File

@@ -5,6 +5,10 @@
* Tener una copia legal de Hollow Knight. * Tener una copia legal de Hollow Knight.
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles * Las versiones de Steam, GOG y Xbox Game Pass son compatibles
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
similares, en vez de sitios web como Nexus Mods.
## Instalación del mod de Archipelago con Lumafly ## Instalación del mod de Archipelago con Lumafly
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight 1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
@@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica.
## Consejos y otros comandos ## Consejos y otros comandos
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto, [guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

View File

@@ -5,6 +5,10 @@
* Uma cópia legal de Hollow Knight. * Uma cópia legal de Hollow Knight.
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados. * Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
ao invés de sites como o Nexus Mods.
## Instalando o mod Archipelago Mod usando Lumafly ## Instalando o mod Archipelago Mod usando Lumafly
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. 1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.

View File

@@ -134,13 +134,13 @@ class KH1Context(CommonContext):
os.makedirs(self.game_communication_path) os.makedirs(self.game_communication_path)
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.close() f.close()
# Handle Slot Data # Handle Slot Data
self.slot_data = args['slot_data'] self.slot_data = args['slot_data']
for key in list(args['slot_data'].keys()): for key in list(args['slot_data'].keys()):
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f: with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w', encoding='utf-8') as f:
f.write(str(args['slot_data'][key])) f.write(str(args['slot_data'][key]))
f.close() f.close()
if key == "remote_location_ids": if key == "remote_location_ids":
@@ -161,7 +161,7 @@ class KH1Context(CommonContext):
found = True found = True
if not found: if not found:
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot: if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
with open(os.path.join(self.game_communication_path, item_filename), 'w') as f: with open(os.path.join(self.game_communication_path, item_filename), 'w', encoding='utf-8') as f:
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player)) f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
f.close() f.close()
self.item_num += 1 self.item_num += 1
@@ -170,7 +170,7 @@ class KH1Context(CommonContext):
if "checked_locations" in args: if "checked_locations" in args:
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.close() f.close()
if cmd in {"PrintJSON"} and "type" in args: if cmd in {"PrintJSON"} and "type" in args:
@@ -195,7 +195,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
if message != "": if message != "":
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.write(message) f.write(message)
f.close() f.close()
if args["type"] == "ItemCheat": if args["type"] == "ItemCheat":
@@ -207,7 +207,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
message = "Received " + itemName + "\nfrom server" message = "Received " + itemName + "\nfrom server"
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.write(message) f.write(message)
f.close() f.close()
@@ -218,7 +218,7 @@ class KH1Context(CommonContext):
logger.info(f"DeathLink: {text}") logger.info(f"DeathLink: {text}")
else: else:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w') as f: with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w', encoding='utf-8') as f:
f.write(str(int(data["time"]))) f.write(str(int(data["time"])))
f.close() f.close()

View File

@@ -0,0 +1,6 @@
{
"game": "Kingdom Hearts 2",
"authors": [ "JaredWeakStrike" ],
"minimum_ap_version": "0.6.3",
"world_version": "2.0.0"
}

View File

@@ -57,6 +57,7 @@ from .patches import bingo as _
from .patches import multiworld as _ from .patches import multiworld as _
from .patches import tradeSequence as _ from .patches import tradeSequence as _
from . import hints from . import hints
from . import utils
from .patches import bank34 from .patches import bank34
from .roomEditor import RoomEditor, Object from .roomEditor import RoomEditor, Object
@@ -231,10 +232,10 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
rom.patch(0, 0x0003, "00", "01") rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around. # Patch the sword check on the shopkeeper turning around.
#if ladxr_settings["steal"] == 'never': if options["stealing"] == Options.Stealing.option_disabled:
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000") rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
#elif ladxr_settings["steal"] == 'always': rom.texts[0x2E] = utils.formatText("Hey! Welcome! Did you know that I have eyes on the back of my head?")
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100") rom.texts[0x2F] = utils.formatText("Nothing escapes my gaze! Your thieving ways shall never prosper!")
#if ladxr_settings["hpmode"] == 'inverted': #if ladxr_settings["hpmode"] == 'inverted':
# patches.health.setStartHealth(rom, 9) # patches.health.setStartHealth(rom, 9)
@@ -325,7 +326,7 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD, 0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
# Prices # Prices
0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items 0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, # Shop items
0x03B, # Trendy Game 0x03B, # Trendy Game
0x045, # Fisherman 0x045, # Fisherman
0x018, 0x019, # Crazy Tracy 0x018, 0x019, # Crazy Tracy

View File

@@ -43,8 +43,12 @@ class World:
self._addEntrance("start_house", mabe_village, start_house, None) self._addEntrance("start_house", mabe_village, start_house, None)
shop = Location("Shop") shop = Location("Shop")
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD)) if options.steal == "inlogic":
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD)) Location().add(ShopItem(0)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 500))))
Location().add(ShopItem(1)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 1480))))
else:
Location().add(ShopItem(0)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 500)))
Location().add(ShopItem(1)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 1480)))
self._addEntrance("shop", mabe_village, shop, None) self._addEntrance("shop", mabe_village, shop, None)
dream_hut = Location("Dream Hut") dream_hut = Location("Dream Hut")

View File

@@ -162,8 +162,8 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
[Hero] Switch version hero mode, double damage, no heart/fairy drops. [Hero] Switch version hero mode, double damage, no heart/fairy drops.
[One hit KO] You die on a single hit, always."""), [One hit KO] You die on a single hit, always."""),
Setting('steal', 'Gameplay', 't', 'Stealing from the shop', Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default', options=[('inlogic', 'a', 'In logic'), ('disabled', 'n', 'Disabled'), ('outoflogic', '', 'Out of logic')], default='outoflogic',
description="""Effects when you can steal from the shop. Stealing is bad and never in logic. description="""Effects when you can steal from the shop and if it is in logic.
[Normal] requires the sword before you can steal. [Normal] requires the sword before you can steal.
[Always] you can always steal from the shop [Always] you can always steal from the shop
[Never] you can never steal from the shop."""), [Never] you can never steal from the shop."""),
@@ -286,7 +286,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
if self.goal in ("bingo", "bingo-full"): if self.goal in ("bingo", "bingo-full"):
req("overworld", "normal", "Bingo goal does not work with dungeondive") req("overworld", "normal", "Bingo goal does not work with dungeondive")
req("accessibility", "all", "Bingo goal needs 'all' accessibility") req("accessibility", "all", "Bingo goal needs 'all' accessibility")
dis("steal", "never", "default", "With bingo goal, stealing should be allowed") dis("steal", "disabled", "default", "With bingo goal, stealing should be allowed")
dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle") dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle")
dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle") dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
if self.overworld == "dungeondive": if self.overworld == "dungeondive":

View File

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

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
import os.path import os.path
import typing import typing
import logging import logging
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool
from collections import defaultdict from collections import defaultdict
import Utils import Utils
@@ -325,6 +325,18 @@ class HardMode(Choice, LADXROption):
default = option_none default = option_none
class Stealing(Choice, LADXROption):
"""
Puts stealing from the shop in logic if the player has a sword.
"""
display_name = "Stealing"
ladxr_name = "steal"
option_in_logic = 1
option_out_of_logic = 2
option_disabled = 3
default = option_out_of_logic
class Overworld(Choice, LADXROption): class Overworld(Choice, LADXROption):
""" """
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes, **Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
@@ -656,6 +668,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
nag_messages: NagMessages nag_messages: NagMessages
ap_title_screen: APTitleScreen ap_title_screen: APTitleScreen
boots_controls: BootsControls boots_controls: BootsControls
stealing: Stealing
quickswap: Quickswap quickswap: Quickswap
hard_mode: HardMode hard_mode: HardMode
low_hp_beep: LowHpBeep low_hp_beep: LowHpBeep
@@ -665,6 +678,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
tarins_gift: TarinsGift tarins_gift: TarinsGift
overworld: Overworld overworld: Overworld
stabilize_item_pool: StabilizeItemPool stabilize_item_pool: StabilizeItemPool
start_inventory_from_pool: StartInventoryPool
warp_improvements: Removed warp_improvements: Removed
additional_warp_points: Removed additional_warp_points: Removed

View File

@@ -97,7 +97,7 @@ def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
"nag_messages", "nag_messages",
"ap_title_screen", "ap_title_screen",
"boots_controls", "boots_controls",
# "stealing", "stealing",
"quickswap", "quickswap",
"hard_mode", "hard_mode",
"low_hp_beep", "low_hp_beep",

View File

@@ -9,6 +9,7 @@ import settings
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from worlds.LauncherComponents import Component, components, SuffixIdentifier, Type, launch, icon_paths
from .Common import * from .Common import *
from . import ItemIconGuessing from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData, from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
@@ -29,6 +30,19 @@ from .Rom import LADXProcedurePatch, write_patch_data
DEVELOPER_MODE = False DEVELOPER_MODE = False
def launch_client(*args):
from .LinksAwakeningClient import launch as ladx_launch
launch(ladx_launch, name=f"{LINKS_AWAKENING} Client", args=args)
components.append(Component(f"{LINKS_AWAKENING} Client",
func=launch_client,
component_type=Type.CLIENT,
icon=LINKS_AWAKENING,
file_identifier=SuffixIdentifier('.apladx')))
icon_paths[LINKS_AWAKENING] = "ap:worlds.ladx/assets/MarinV-3_small.png"
class LinksAwakeningSettings(settings.Group): class LinksAwakeningSettings(settings.Group):
class RomFile(settings.UserFilePath): class RomFile(settings.UserFilePath):
"""File name of the Link's Awakening DX rom""" """File name of the Link's Awakening DX rom"""
@@ -211,8 +225,6 @@ class LinksAwakeningWorld(World):
def create_items(self) -> None: def create_items(self) -> None:
itempool = [] itempool = []
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ] self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
self.prefill_own_dungeons = [] self.prefill_own_dungeons = []
self.pre_fill_items = [] self.pre_fill_items = []
@@ -229,50 +241,46 @@ class LinksAwakeningWorld(World):
continue continue
item_name = ladxr_item_to_la_item_name[ladx_item_name] item_name = ladxr_item_to_la_item_name[ladx_item_name]
for _ in range(count): for _ in range(count):
if item_name in exclude: item = self.create_item(item_name)
exclude.remove(item_name) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
else:
item = self.create_item(item_name)
if not self.options.tradequest and isinstance(item.item_data, TradeItemData): if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
location.place_locked_item(item) location.place_locked_item(item)
location.show_in_spoiler = False location.show_in_spoiler = False
continue continue
if isinstance(item.item_data, DungeonItemData): if isinstance(item.item_data, DungeonItemData):
item_type = item.item_data.ladxr_id[:-1] item_type = item.item_data.ladxr_id[:-1]
shuffle_type = self.dungeon_item_types[item_type] shuffle_type = self.dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock # Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please # TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False found = False
for r in self.multiworld.get_regions(self.player): for r in self.multiworld.get_regions(self.player):
if r.dungeon_index != item.item_data.dungeon_index: if r.dungeon_index != item.item_data.dungeon_index:
continue
for loc in r.locations:
if not isinstance(loc, LinksAwakeningLocation):
continue continue
for loc in r.locations: if not isinstance(loc.ladxr_item, Instrument):
if not isinstance(loc, LinksAwakeningLocation): continue
continue loc.place_locked_item(item)
if not isinstance(loc.ladxr_item, Instrument): found = True
continue break
loc.place_locked_item(item) if found:
found = True break
break
if found:
break
else:
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
else: else:
itempool.append(item) if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
else:
itempool.append(item)
self.multi_key = self.generate_multi_key() self.multi_key = self.generate_multi_key()

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -73,9 +73,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)". 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)".
#### BizHawk 2.8 or newer (older versions untested) #### BizHawk 2.8 or newer (older versions untested)

View File

@@ -48,7 +48,7 @@ A window will open with a few settings to enter:
- **Slot name**: Put the player name you specified in your YAML config file in this field. - **Slot name**: Put the player name you specified in your YAML config file in this field.
- **Password**: If the server has a password, put it there. - **Password**: If the server has a password, put it there.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_ap.png) ![Landstalker Archipelago Client user interface](ls_guide_ap.png)
Once all those fields were filled appropriately, click on the `Connect to Archipelago` button below to try connecting to Once all those fields were filled appropriately, click on the `Connect to Archipelago` button below to try connecting to
the Archipelago server. the Archipelago server.
@@ -67,7 +67,7 @@ You should see a window with settings to fill:
- **Output ROM directory**: This is where the randomized ROMs will be put. No need to change this unless you want them - **Output ROM directory**: This is where the randomized ROMs will be put. No need to change this unless you want them
to be created in a very specific folder. to be created in a very specific folder.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_rom.png) ![Landstalker Archipelago Client user interface](ls_guide_rom.png)
There also a few cosmetic options you can fill before clicking the `Build ROM` button which should create your There also a few cosmetic options you can fill before clicking the `Build ROM` button which should create your
randomized seed if everything went right. randomized seed if everything went right.
@@ -83,7 +83,7 @@ the items you have received from other players.
You should see the following window: You should see the following window:
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_emu.png) ![Landstalker Archipelago Client user interface](ls_guide_emu.png)
As written, you have to open the newly generated ROM inside either Retroarch or Bizhawk using the Genesis Plus GX core. As written, you have to open the newly generated ROM inside either Retroarch or Bizhawk using the Genesis Plus GX core.
Be careful to select that core, because any other core (e.g. BlastEm) won't work. Be careful to select that core, because any other core (e.g. BlastEm) won't work.
@@ -116,6 +116,6 @@ The client is packaged with both an **automatic item tracker** and an **automati
If you don't know all checks in the game, don't be afraid: you can click the `Where is it?` button that will show If you don't know all checks in the game, don't be afraid: you can click the `Where is it?` button that will show
you a screenshot of where the location actually is. you a screenshot of where the location actually is.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_client.png) ![Landstalker Archipelago Client user interface](ls_guide_client.png)
Have fun! Have fun!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -106,9 +106,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -106,26 +106,38 @@ def tree_zone_4_midway_bell(state, player):
def tree_zone_4_coins(state, player, coins): def tree_zone_4_coins(state, player, coins):
auto_scroll = is_auto_scroll(state, player, "Tree Zone 4") auto_scroll = is_auto_scroll(state, player, "Tree Zone 4")
reachable_coins = 0 entryway = 14
hall = 4
first_trip_downstairs = 31
second_trip_downstairs = 15
downstairs_with_auto_scroll = 12
final_room = 10
reachable_coins_from_start = 0
reachable_coins_from_bell = 0
if has_pipe_up(state, player): if has_pipe_up(state, player):
reachable_coins += 14 reachable_coins_from_start += entryway
if has_pipe_right(state, player): if has_pipe_right(state, player):
reachable_coins += 4 reachable_coins_from_start += hall
if has_pipe_down(state, player): if has_pipe_down(state, player):
reachable_coins += 10 if auto_scroll:
if not auto_scroll: reachable_coins_from_start += downstairs_with_auto_scroll
reachable_coins += 46 else:
elif state.has("Tree Zone 4 Midway Bell", player): reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs
if not auto_scroll: if state.has("Tree Zone 4 Midway Bell", player):
if has_pipe_left(state, player): if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)):
reachable_coins += 18 reachable_coins_from_bell += final_room
if has_pipe_down(state, player): elif has_pipe_left(state, player) and not auto_scroll:
reachable_coins += 10 if has_pipe_down(state, player):
reachable_coins_from_bell += first_trip_downstairs
if has_pipe_right(state, player):
reachable_coins_from_bell += entryway + hall
if has_pipe_up(state, player): if has_pipe_up(state, player):
reachable_coins += 46 reachable_coins_from_bell += second_trip_downstairs + final_room
elif has_pipe_down(state, player): else:
reachable_coins += 10 reachable_coins_from_bell += entryway + hall
return coins <= reachable_coins return coins <= max(reachable_coins_from_start, reachable_coins_from_bell)
def tree_zone_5_boss(state, player): def tree_zone_5_boss(state, player):
@@ -239,12 +251,9 @@ def pumpkin_zone_4_coins(state, player, coins):
def mario_zone_1_normal_exit(state, player): def mario_zone_1_normal_exit(state, player):
if has_pipe_right(state, player): return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1")
if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player): or state.has_any(["Mushroom", "Fire Flower", "Carrot",
return True "Mario Zone 1 Midway Bell"], player))
if is_auto_scroll(state, player, "Mario Zone 1"):
return True
return False
def mario_zone_1_midway_bell(state, player): def mario_zone_1_midway_bell(state, player):

View File

@@ -1,5 +1,5 @@
{ {
"game": "Mega Man 2", "game": "Mega Man 2",
"world_version": "0.3.2", "world_version": "0.3.3",
"minimum_ap_version": "0.6.4" "minimum_ap_version": "0.6.4"
} }

View File

@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from . import MM2World from . import MM2World
MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497"
PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61"
MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632"
MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3"
@@ -327,8 +327,6 @@ def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None:
patch.write_byte(0x36089, pool[18]) # Intro patch.write_byte(0x36089, pool[18]) # Intro
patch.write_byte(0x361F1, pool[19]) # Title patch.write_byte(0x361F1, pool[19]) # Title
from Utils import __version__ from Utils import __version__
patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
'utf8')[:21] 'utf8')[:21]
@@ -406,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str:
return file_name return file_name
PRG_OFFSET = 0x8ED70 PRG_OFFSET = 0x8F170
PRG_SIZE = 0x40000 PRG_SIZE = 0x40000

View File

@@ -58,6 +58,10 @@ FlashFixTarget1:
%org($808D, $0B) %org($808D, $0B)
FlashFixTarget2: FlashFixTarget2:
%org($A65C, $0B)
HeatFix:
CMP #$FF
%org($8015, $0D) %org($8015, $0D)
ClearRefreshHook: ClearRefreshHook:
; if we're already doing a fresh load of the stage select ; if we're already doing a fresh load of the stage select

View File

@@ -0,0 +1,6 @@
{
"game": "Ocarina of Time",
"authors": ["espeon65536"],
"world_version": "7.0.0",
"minimum_ap_version": "0.6.4"
}

View File

@@ -84,7 +84,7 @@ Wenn du einer Multiworld beitrittst, wirst du gefordert eine YAML-Datei bei dem
erhälst du (in der Regel) einen Link vom Host der Multiworld. Dieser führt dich zu einem Raum, in dem alle erhälst du (in der Regel) einen Link vom Host der Multiworld. Dieser führt dich zu einem Raum, in dem alle
teilnehmenden Spieler (bzw. Welten) aufgelistet sind. Du solltest dich dann auf **deine** Welt konzentrieren teilnehmenden Spieler (bzw. Welten) aufgelistet sind. Du solltest dich dann auf **deine** Welt konzentrieren
und klicke dann auf `Download APZ5 File...`. und klicke dann auf `Download APZ5 File...`.
![Screenshot of a Multiworld Room with an Ocarina of Time Player](/static/generated/docs/Ocarina%20of%20Time/MultiWorld-room_oot.png) ![Screenshot of a Multiworld Room with an Ocarina of Time Player](MultiWorld-Room_oot.png)
Führe die `.apz5`-Datei mit einem Doppelklick aus, um deinen Ocarina Of Time-Client zu starten, sowie das patchen Führe die `.apz5`-Datei mit einem Doppelklick aus, um deinen Ocarina Of Time-Client zu starten, sowie das patchen
deiner ROM. Ist dieser Prozess fertig (kann etwas dauern), startet sich der Client und der Emulator automatisch deiner ROM. Ist dieser Prozess fertig (kann etwas dauern), startet sich der Client und der Emulator automatisch

View File

@@ -1237,7 +1237,7 @@ saffron_gym_warps = [
entrance_only = [ entrance_only = [
"Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F", "Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F",
"Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F" "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F",
"Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W", "Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W",
"Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House", "Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House",
"Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E", "Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E",

View File

@@ -34,6 +34,7 @@ code contributors also reported bugs and participated in beta testing.
* 7thAce (@7thAce) - Pulsar * 7thAce (@7thAce) - Pulsar
* Panicmoon (@panicmoon.bsky.social) - Skylord * Panicmoon (@panicmoon.bsky.social) - Skylord
* JayborinoPlays (@Jayborino) - Oppressor * JayborinoPlays (@Jayborino) - Oppressor
* MindHawk (@MindHawk) - Caladrius
## Maintenance of 2024 release ## Maintenance of 2024 release
* Ziktofel (@Ziktofel) * Ziktofel (@Ziktofel)

View File

@@ -209,7 +209,7 @@ bread_and_butter_settings = {
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default, OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal, OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
OPTION_NAME[MissionOrder]: MissionOrder.option_blitz, OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path,
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
@@ -331,12 +331,13 @@ evil_logic_settings = {
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal, OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
OPTION_NAME[MissionOrder]: MissionOrder.option_grid, OPTION_NAME[MissionOrder]: MissionOrder.option_grid,
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units,
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced, OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines, OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
OPTION_NAME[MaximumCampaignSize]: 35, OPTION_NAME[MaximumCampaignSize]: 35,
OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true,
OPTION_NAME[StarterUnit]: StarterUnit.option_off, OPTION_NAME[StarterUnit]: StarterUnit.option_off,
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true, OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false, OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false,

View File

@@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) ->
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
includeuseful: bool) -> bool: includeuseful: bool, floating: bool) -> bool:
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and
@@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list
return False return False
if buildings[index] == ITEMS.cutter: if buildings[index] == ITEMS.cutter:
if buildings.index(ITEMS.stacker) < index: if buildings.index(ITEMS.stacker) < index and not floating:
return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player) return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player)
else: else:
return can_cut_half(state, player) return can_cut_half(state, player)
@@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool,
# Progressively connect level and upgrade regions # Progressively connect level and upgrade regions
regions[REGIONS.main].connect( regions[REGIONS.main].connect(
regions[REGIONS.levels_1], "Using first level building", regions[REGIONS.levels_1], "Using first level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating))
regions[REGIONS.levels_1].connect( regions[REGIONS.levels_1].connect(
regions[REGIONS.levels_2], "Using second level building", regions[REGIONS.levels_2], "Using second level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating))
regions[REGIONS.levels_2].connect( regions[REGIONS.levels_2].connect(
regions[REGIONS.levels_3], "Using third level building", regions[REGIONS.levels_3], "Using third level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 2, lambda state: has_logic_list_building(state, player, level_logic_buildings, 2,
early_useful == OPTIONS.buildings_3)) early_useful == OPTIONS.buildings_3, floating))
regions[REGIONS.levels_3].connect( regions[REGIONS.levels_3].connect(
regions[REGIONS.levels_4], "Using fourth level building", regions[REGIONS.levels_4], "Using fourth level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating))
regions[REGIONS.levels_4].connect( regions[REGIONS.levels_4].connect(
regions[REGIONS.levels_5], "Using fifth level building", regions[REGIONS.levels_5], "Using fifth level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 4, lambda state: has_logic_list_building(state, player, level_logic_buildings, 4,
early_useful == OPTIONS.buildings_5)) early_useful == OPTIONS.buildings_5, floating))
regions[REGIONS.main].connect( regions[REGIONS.main].connect(
regions[REGIONS.upgrades_1], "Using first upgrade building", regions[REGIONS.upgrades_1], "Using first upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating))
regions[REGIONS.upgrades_1].connect( regions[REGIONS.upgrades_1].connect(
regions[REGIONS.upgrades_2], "Using second upgrade building", regions[REGIONS.upgrades_2], "Using second upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating))
regions[REGIONS.upgrades_2].connect( regions[REGIONS.upgrades_2].connect(
regions[REGIONS.upgrades_3], "Using third upgrade building", regions[REGIONS.upgrades_3], "Using third upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2, lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2,
early_useful == OPTIONS.buildings_3)) early_useful == OPTIONS.buildings_3, floating))
regions[REGIONS.upgrades_3].connect( regions[REGIONS.upgrades_3].connect(
regions[REGIONS.upgrades_4], "Using fourth upgrade building", regions[REGIONS.upgrades_4], "Using fourth upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating))
regions[REGIONS.upgrades_4].connect( regions[REGIONS.upgrades_4].connect(
regions[REGIONS.upgrades_5], "Using fifth upgrade building", regions[REGIONS.upgrades_5], "Using fifth upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4, lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4,
early_useful == OPTIONS.buildings_5)) early_useful == OPTIONS.buildings_5, floating))
# Connect Uncolored shapesanity regions to Main # Connect Uncolored shapesanity regions to Main
regions[REGIONS.main].connect( regions[REGIONS.main].connect(

View File

@@ -124,9 +124,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -100,9 +100,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -120,9 +120,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -108,8 +108,8 @@ You only have to do these steps once.
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png) ![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

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