Compare commits

...

108 Commits

Author SHA1 Message Date
Zach Parks
3852a14ba2 Remove Python 3.9+ exclusive code from WebHost options.py.
`str.removeprefix` was added in Python 3.9, so this causes WebHost to return a 500 error if running on Python 3.8.
2024-06-20 14:36:41 -05:00
Justus Lind
4f514e5944 Muse Dash: Song name change (#3572)
* Change the song name of the removed song to the one replacing it.

* Make it not part of Streamable songs for now.
2024-06-20 13:54:38 +02:00
Aaron Wagener
f515a085db The Messenger: Fix missing rules for Double Swing Saws (#3562)
* The Messenger: Fix missing rules for Double Swing Saws

* i put it in the wrong dictionary

* remove unnecessary call
2024-06-19 16:20:47 +02:00
eudaimonistic
903a0bab1a Docs: Change setup_en.md to use Latest releases page (#3543)
* Change setup_en.md to use Latest releases page

Really simple change to point users to the Latest release page instead of the Releases page.  Saw a user accidentally download 0.3.6 because it was the last item on the page (they're accustomed to scrolling down to the bottom of the page in GitHub for the Assets section), and this change prevents that outright.

* Update setup_en.md

Rewrite text and link to restore semantic compatibility.
2024-06-19 16:12:25 +02:00
Kaito Sinclaire
9bb3947d7e Doom 2, Heretic: fix missing items (Doom2 Megasphere, Heretic Torch) (#3561)
for doom 2, some of the armor and health weights were nudged down
to compensate for the addition of the megasphere

for heretic, the torch was just added without changing anything else,
as I felt doing so would negatively impact the distribution of
artifacts (and personally I already feel there's too few in a game)
2024-06-19 12:59:10 +02:00
Mrks
240d1a3bbf LADX: Adding 'Option Groups' to the player options page. (#3560)
* Adding 'Option Groups' to the LADX player options page.

* Moved 'Miscellaneous' group to the logic effecting groups.
2024-06-19 08:40:10 +02:00
Kory Dondzila
b6191ff7ca Shivers: Adds missing indirect conditions. (#3558)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-18 05:10:54 +02:00
Scipio Wright
19d00547c2 TUNIC: Add note about bushes to logic section of game info page (#3555)
* Add note about bushes to logic section of readme

* Update worlds/tunic/docs/en_TUNIC.md

Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>

---------

Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
2024-06-18 04:51:54 +02:00
chesslogic
67a0a04917 Tests: minor: update tests base for Options API (#2516)
* update tests for Options API

* The actual "bug"

* resolve qwint's comment from 3 months ago
2024-06-18 04:49:26 +02:00
Star Rauchenberger
af213c9e5d LADX: Converted to new options API (+other small refactors) (#3542)
* Refactored various things

* Renamed hidden variable in dungeon item shuffle block

* Fixed LADXRSettings initialization

* Rename ladxr_options -> ladxr_settings

* Remove unnecessary int cast

* Update worlds/ladx/LADXR/generator.py

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-06-18 04:48:15 +02:00
Zach Parks
898509e7ee CODEOWNERS: Remove @zig-for as world maintainer for LADX. (#3525)
Per request: https://discord.com/channels/731205301247803413/1214608557077700720/1250714693136547920
2024-06-16 05:38:08 -05:00
Zach Parks
1f685b4272 CommonClient: Use lookup_in_game instead of lookup_in_slot in case of own-game name lookup when disconnected from server. (#3514) 2024-06-16 05:37:05 -05:00
Scipio Wright
c622240730 Tunc: Update plando connections description (#3545) 2024-06-16 05:02:48 +02:00
Mrks
1d314374d7 LADX: Moved ROM requirement from generate_output to stage_assert_generate. (#3540)
Co-authored-by: Mrks <markus.burmeister@mburm.de>
2024-06-16 04:31:32 +02:00
palex00
753eb8683f Pokemon Red/Blue: Replaces link to R&B Poptracker with a new one (#3516)
* Update setup_en.md

* Update setup_es.md
2024-06-16 04:10:50 +02:00
Fabian Dill
e8542b8acd Generate: split ERmain out of main (#3515) 2024-06-16 03:27:06 +02:00
NewSoupVi
2a11d610b6 The Witness: Fix Shuffle Postgame always thinking it's Challenge Victory (#3504)
* Fix postgame thinking it's the wrong panel

* Also don't have a default value for it so it doesn't happen again
2024-06-16 01:56:20 +02:00
coveleski
92023a2cb5 Pokemon RB: Add new options to slot_data (#3538)
Added require_pokedex, blind_trainers, and area_1_to_1 mapping, which would be helpful to the poptracker packs to accurately reflect the checks available to players.
2024-06-16 01:55:52 +02:00
Fabian Dill
df94271d30 LttP: fix single-player no-logic generation (#3454) 2024-06-15 19:18:26 +02:00
Bryce Wilson
0354315c22 Pokemon Emerald: Remove README (#3532) 2024-06-15 04:52:01 +02:00
Star Rauchenberger
e796f0ae64 Core: Expose option aliases (#3512) 2024-06-15 04:50:26 +02:00
Natalie Weizenbaum
c61505baf6 WebHost/Core/Lingo: Render option documentation as reStructuredText in the WebView (#3511)
* Render option documentation as reStructuredText in the WebView

This means that options can use the standard Python documentation
format, while producing much nicer-looking documentation in the
WebView with things like emphasis, lists, and so on.

* Opt existing worlds out of rich option docs

This avoids breaking the rendering of existing option docs which were
written with the old plain text rendering in mind, while also allowing
new options to default to the rich text rendering instead.

* Use reStructuredText formatting for Lingo Options docstrings

* Disable raw and file insertion RST directives

* Update doc comments per code review

* Make rich text docs opt-in

* Put rich_text_options_doc on WebWorld

* Document rich text API

* Code review

* Update docs/options api.md

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

* Update Options.py

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

---------

Co-authored-by: Chris Wilson <chris@legendserver.info>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-06-14 18:53:42 -04:00
Fabian Dill
3972b1b257 Options: fix yaml export corner case (#3529) 2024-06-15 00:48:49 +02:00
black-sliver
1fe3d842c8 CI: Install specific inno version (#3526)
* CI: Install specific inno version

* great mobile dev experience

* maybe this

* really don't enjoy PS

* Anothet attempt

* maybe fix log

* slowly going mad

* fml

* allow downgrade
2024-06-14 08:47:47 +02:00
Fabian Dill
e9ad7cb797 WebHost: fix option doc indent (#3513)
* WebHost: fix option doc indent

* Update macros.html
2024-06-13 17:37:52 -04:00
NewSoupVi
533395d336 WebHost: Fix Named Range displays on Player Options page (#3521)
* Player Options: Fix Named Range displays

* Also add validation to the NamedRange class itself

* Don't break Stardew

* Comment

* Do replace first so title works correctly

* Bring change to Weighted Options as well
2024-06-13 17:29:39 -04:00
NewSoupVi
2ae51364d9 WebHost: Fix default values that are 2 or more words in Weighted Options (#3519)
* WeightedOptions: Fix default values that are 2 or more words

* So much simpler
2024-06-13 12:24:56 -04:00
NewSoupVi
f6e3113af6 WebHost: Fix "Add" button for custom option values causing a weird redirect (#3518)
* WebHost: Fix "Add" button for Progression Balancing causing a weird redirect

This "add" button is part of a form, which causes it to submit the form, because the default type for a button is "submit".

This PR changes the type of the button to "button", which causes it to not submit the form and just execute its normal effect.

(An alternative would be `event.preventDefault()` but that seems less clean to me, but also I'm not a HTML/JS dev)

* There's also multiple.
2024-06-13 04:39:16 -04:00
JoshuaEagles
da34800f43 Fix Incorrect Link Syntax in SA2B Linux Setup (#3524) 2024-06-13 06:53:01 +02:00
black-sliver
c108845d1f CI: more checks in build and rework compression (#3336)
* CI: build: fail fast if setup.py fails on windows

* CI: build: fail for missing uploads, rework compression

Upload-artifact allows setting compression level now.
The change speeds up both upload and extraction.

* CI: match build gz in release

* CI: build: verify worlds all load

* CI: build: generate a game

* Generate: move worlds loaded exception to allow settings to init from worlds

* CI: build: build setup before running tests
2024-06-12 18:55:48 +02:00
black-sliver
acf85eb9ab Speedups: remove dependency on c++ (#2796)
* Speedups: remove dependency on c++

* Speedups: intset: handle malloc failing

* Speedups: intset: fix corner case for int64 on 32bit systems

original idea was to only use bucket->val if int<pointer,
but we always have a union now anyway

* Speedups: add size comment to player_set bucket configuration

* test: more tests for LocationStore.find_item

* test: require _speedups in CI

This kind of tests that the build succeeds.

* test: even more tests for LocationStore.find_item

* Speedups: intset uniform comment style

* Speedups: intset: avoid memory leak when realloc fails

* Speedups: intset: make `gcc -pedantic -std=c99 -fanalyzer` without warnings

Unnamed unions are not in C99, this got fixed.
The overhead of setting count=0 is minimal or optimized-out and silences -fanalizer (see comment).

* Speedups: don't leak memory in case of exception

* Speedups: intset: validate alloc and free

This won't happen in our cython, but it's still a good addition.

* CI: add test framework for C/C++ code

* CI: ctest: fix cwd

* Speedups: intset: ignore msvc warning

* Tests: intset: revert attempt at no-asan

We solve this with env vars in ctest now, and this fails for msvc.

* Test: cpp: docs: fix typo

* Test: cpp: docs: fix another typo

* Test: intset: proper bucket count for Negative test

INTxx_MIN % 1 would not produce a negative number, so the test was flawed.
2024-06-12 18:54:59 +02:00
Fabian Dill
2daccded36 Core: don't lock progression (#3501) 2024-06-12 15:35:51 +02:00
Fabian Dill
3b9b9353b7 WebHost: delete old docs files (#3503) 2024-06-12 15:34:46 +02:00
Silvris
b9e454ab4e TS: add indirect connections (#3490) 2024-06-12 03:23:46 +02:00
Natalie Weizenbaum
7299891bdf Allow worlds to add options to prebuilt groups (#3509)
Previously, this crashed because `typing.NamedTuple` fields such as
`group.name` aren't assignable. Now it will only fail for group names
that are actually incorrectly cased, and will fail with a better error
message.
2024-06-12 03:22:14 +02:00
Fabian Dill
e755f1a0b5 SC2: don't close all SC2 instances when one quits (#3507) 2024-06-12 02:14:30 +02:00
Louis M
87d24eb38a Aquaria: Add entrance rule and fix start_inventory_from_pool (#3473) 2024-06-11 17:59:46 -05:00
Justus Lind
54531c6eba Muse Dash: Remove regions for a decent speed gain in generating worlds (#3435)
* Remove Muse Dash Regions.

* Update comments.
2024-06-11 03:11:19 +02:00
Zach Parks
ccfffa1147 CODEOWNERS: Replace @ThePhar with @qwint as Hollow Knight maintainer. (#3508) 2024-06-10 18:55:02 -05:00
Fabian Dill
75bef3ddb1 Various: fix absolute imports in worlds (#3489) 2024-06-11 00:42:57 +02:00
JusticePS
484082616f Adventure: Update to use new options api (#3326) 2024-06-11 00:42:01 +02:00
Aaron Wagener
35617bdac5 Tests: Add checksum validation to the postgen datapackage test (#3456)
* Tests: Add checksum validation to the postgen datapackage test

* add a special case for the test world datapackage rather than hidden

* add the test world to the datapackage instead of special casing around it

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-06-10 09:28:28 +02:00
Phaneros
0a912808e3 SC2: update inno_setup.iss to remove old sc2wol world folder (#3495) 2024-06-10 02:05:39 +02:00
Phaneros
84a6d50ae7 sc2: Fixed sc2 client's /received command breaking after PR 1933 merged (#3497) 2024-06-09 16:55:05 +02:00
jamesbrq
5f8a8e6dad Update Rom.py (#3498) 2024-06-09 16:54:07 +02:00
Phaneros
2198a70251 Core: CommonClient: command history and echo (#3236)
* client: Added command history access with up/down and command echo in common client

* client: Changed command echo colour to orange

* client: removed star import from typing

* client: updated code style to match style guideline

* client: adjusted ordering of calling parent constructor in command prompt input constructor

* client: Fixed issues identified by beauxq in PR; fixed some typing issues

* client: PR comments; replaced command history list with deque
2024-06-09 04:08:47 +02:00
Fabian Dill
c478e55d7a Generate: improve logging capture (#3484) 2024-06-09 03:13:27 +02:00
Fabian Dill
76804d295b Core: explicitly import importlib.util (#3224) 2024-06-08 20:04:17 +02:00
Fabian Dill
0d9fce29c6 Core: load frozen decompressed worlds (#3488) 2024-06-08 19:58:58 +02:00
black-sliver
302017c69e Test: hosting: handle writes during start_room (#3492)
Note: maybe we'd also want to add such handling to WebHost itself,
      but this is out of scope for getting hosting test to work.
2024-06-08 17:51:09 +02:00
qwint
a0653cdfe0 HK: adds split movement items to skills item group (#3462) 2024-06-08 17:31:27 +02:00
Fabian Dill
89d584e474 WebHost: allow getting checksum-specific datapackage via /api/datapackage/<checksum> (#3451)
* WebHost: allow getting checksum-specific datapackage via /api/datapackage/<checksum>

* match import style of /api/generate
2024-06-08 05:07:14 -04:00
Chris Wilson
39deef5d09 Fix Choice and TextChoice options crashing WebHost if the option's default value is "random" (#3458) 2024-06-08 04:54:14 -04:00
Exempt-Medic
b3a2473853 Docs: Fixing subject-verb agreement (#3491) 2024-06-08 05:47:02 +02:00
qwint
b053fee3e5 HK: adds schema to validate plando charm costs (#3471) 2024-06-07 19:12:10 +02:00
Trevor L
8c614865bb Bomb Rush Cyberfunk: Fix missing location (#3475) 2024-06-07 19:11:35 +02:00
Silent
d72afe7100 Update setup_en.md (#3483) 2024-06-07 17:45:22 +02:00
chandler05
223f2f5523 A Short Hike: Update installation instructions (#3474)
* A Short Hike: Update installation instructions

* Update setup_en.md

* Update setup_en.md

* Change link
2024-06-06 22:57:50 +02:00
Scipio Wright
31419c84a4 TUNIC: Remove rule for west Quarry bomb wall (#3481)
* Update west quarry bomb wall rule

* Update west quarry bomb wall rule
2024-06-06 22:56:35 +02:00
Doug Hoskisson
6bb1cce43f Core: hot reload components from installed apworld (#3480)
* Core: hot reload components from installed apworld

* address PR reviews

`Launcher` widget members default to `None` so they can be defined in `build`

`Launcher._refresh_components` is not wrapped

loaded world goes into `world_sources` so we can check if it's already loaded.
(`WorldSource` can be ordered now without trying to compare `None` and `float`)
(don't load empty directories so we don't detect them as worlds)

* clarify that the installation is successful
2024-06-06 20:36:14 +02:00
black-sliver
808f2a8ff0 Core: update dependencies (#3477) 2024-06-06 19:27:01 +02:00
Doug Hoskisson
7f1e95c04c Core: gitignore custom_worlds (#3479) 2024-06-06 09:02:29 +02:00
NewSoupVi
86da3eb52c Remove all functools lru cache (#3446) 2024-06-06 03:40:47 +02:00
black-sliver
afb6d9c4da MultiServer, customserver, CI, Test: Fix problems in room hosting and test/simulate it (#3464)
* Test: add hosting simulation test

* WebHost: add weak typing to get_app()

* MultiServer: add typing to auto_saver_thread

* MultiServer: don't cancel task, properly end it

* customserver: stop auto-save thread from saving after shutdown

and make sure it stops, another potential memory leak

* MultiServer, customserver: make datapackage small again

* customserver: collect/finish room tasks

Hopefully fixes the memory leak we are seeing

* CI: test hosting

* Test: hosting: verify autohoster saves on Ctrl+C

* customserver: save when stopping via Ctrl+C
2024-06-06 01:54:46 +02:00
black-sliver
911eba3202 WebHost: update dependencies (#3476) 2024-06-06 01:51:05 +02:00
Fabian Dill
93cd13736a Launcher: handle apworld installation (#3472) 2024-06-06 01:36:02 +02:00
chandler05
c554c3fdae A Short Hike: Add new options and option groups (#3410)
* A Short Hike: New options and stuff

* Add to slot data for poptracker

* Address concerns

* Address concerns

* Fix indentations

* Update option description

* Address all issues

* Group "or"s
2024-06-06 00:50:30 +02:00
Aaron Wagener
be03dca774 Core: add unit tests and more documentation for numeric options (#2926)
* Core: add unit tests for the numeric options

* document using a collection and the hashing quirk

* add another example for the footgun

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-06 00:17:52 +02:00
Fabian Dill
04ec2f3893 Setup: delete old world folders (#3469) 2024-06-05 22:26:13 +02:00
Fabian Dill
afe4b2925e Setup: rename ArchipelagoLauncher(DEBUG) to ArchipelagoLauncherDebug (#3468) 2024-06-05 21:00:53 +02:00
qwint
da2f0f94ca HK: lower max egg cost (#3463) 2024-06-05 00:01:22 -05:00
Doug Hoskisson
6a60a93092 Zillion: fix some game over bugs (#3466)
There was a bug that made lots of flashing terrain if a game over happened in certain places.
(And this could be dangerous for people sensitive to flashing lights.)

There was also a bug with a bad sound effect after a game over.
2024-06-04 21:56:32 -07:00
Doug Hoskisson
76266f25ef Core: Launcher: can drag-and-drop patch on Launcher window (#3442)
* Core: Launcher: can drag-and-drop patch on Launcher window

* doc string for `_on_drop_file`
2024-06-05 01:54:21 +02:00
Aaron Wagener
3cc391e9a1 Docs: Add detail on customizing the forced groups (#3371)
* Docs: Fix incorrect assertion in option group docs and add detail on customizing the forced groups.

* add docs for the visibility attribute

* typos

* review comments

* missed one

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

* better wording

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-06-04 21:52:07 +02:00
Justus Lind
133167564c Muse Dash: Option Groups and Options Rework (#3434)
* Ensure that included/starter songs only include those within enabled dlcs.

* Allow filtering traps by trap instead of by category.

* Add in the currently available limited time dlcs to the dlc list.

* Add the option group to the webhost and cleanup some errors.

* Fix trap list.

* Update tests. Add new ones to test correctness of new features.

* Remove the old Just As Planned option

* Make traps order alphabetically. Also adjust the title for traps.

* Adjust new lines to better fit the website.

* Style fixes.

* Test adjustments and a fix due to test no longer having just as planned dlc.

* Undo spacing changes as it breaks yaml generation.

* Fix indenting in webhost.

* Add the old options in as removed. Also clean up unused import.

* Remove references to the old allow_just_as_planned_dlc_songs option in Muse Dash tests.

* Add newline to end of file.

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-04 21:45:26 +02:00
Rjosephson
f30f2d3a3f RoR2: Add Support for New Stage (#3436)
* add support for the new stage added to RoR2

* Fix stage being unreachable

* add option groups

* reorder option groups
2024-06-04 21:24:14 +02:00
Bryce Wilson
ee1b13f219 Pokemon Emerald: Fix possible dexsanity/legendary hunt softlock (#3443)
* Pokemon Emerald: Remove mirage tower from allowed dexsanity maps

* Pokemon Emerald: Prevent placing wailord/relicanth in out of logic maps

* Pokemon Emerald: Clarify docstring

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

* Pokemon Emerald: Update changelog

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-06-04 21:21:58 +02:00
Exempt-Medic
c4572964ec KH2: Fixing Start Inventory bug, limiting CustomItemPool keys, fixing two typos (#3444)
* Fixing inclusion checking error

* Fixing typo, limiting valid keys to valid keys

* Adding space

* Add period
2024-06-04 21:20:37 +02:00
CookieCat
16ae8449f4 AHIT: Fix Death Wish location rules not being added properly (#3455)
* duh

* Fuck it

* Major fixes

* a

* b

* Even more fixes

* New option - NoFreeRoamFinale

* a

* Hat Logic Fix

* Just to be safe

* multiworld.random to world.random

* KeyError fix

* Update .gitignore

* Update __init__.py

* Zoinks Scoob

* ffs

* Ruh Roh Raggy, more r-r-r-random bugs!

* 0.9b - cleanup + expanded logic difficulty

* Update Rules.py

* Update Regions.py

* AttributeError fix

* 0.10b - New Options

* 1.0 Preparations

* Docs

* Docs 2

* Fixes

* Update __init__.py

* Fixes

* variable capture my beloathed

* Fixes

* a

* 10 Seconds logic fix

* 1.1

* 1.2

* a

* New client

* More client changes

* 1.3

* Final touch-ups for 1.3

* 1.3.1

* 1.3.3

* Zero Jumps gen error fix

* more fixes

* Formatting improvements

* typo

* Update __init__.py

* Revert "Update __init__.py"

This reverts commit e178a7c0a6.

* init

* Update to new options API

* Missed some

* Snatcher Coins fix

* Missed some more

* some slight touch ups

* rewind

* a

* fix things

* Revert "Merge branch 'main' of https://github.com/CookieCat45/Archipelago-ahit"

This reverts commit a2360fe197, reversing
changes made to b8948bc495.

* Update .gitignore

* 1.3.6

* Final touch-ups

* Fix client and leftover old options api

* Delete setup-ahitclient.py

* Update .gitignore

* old python version fix

* proper warnings for invalid act plandos

* Update worlds/ahit/docs/en_A Hat in Time.md

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>

* Update worlds/ahit/docs/setup_en.md

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>

* 120 char per line

* "settings" to "options"

* Update DeathWishRules.py

* Update worlds/ahit/docs/en_A Hat in Time.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* No more loading the data package

* cleanup + act plando fixes

* almost forgot

* Update Rules.py

* a

* Update worlds/ahit/Options.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* Options stuff

* oop

* no unnecessary type hints

* warn about depot download length in setup guide

* Update worlds/ahit/Options.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* typo

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* Update worlds/ahit/Rules.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* review stuff

* More stuff from review

* comment

* 1.5 Update

* link fix?

* link fix 2

* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* Evil

* Good fucking lord

* Review stuff again + Logic fixes

* More review stuff

* Even more review stuff - we're almost done

* DW review stuff

* Finish up review stuff

* remove leftover stuff

* a

* assert item

* add A Hat in Time to readme/codeowners files

* Fix range options not being corrected properly

* 120 chars per line in docs

* Update worlds/ahit/Regions.py

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

* Update worlds/ahit/DeathWishLocations.py

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

* Remove some unnecessary option.class.value

* Remove data_version and more option.class.value

* Update worlds/ahit/Items.py

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

* Remove the rest of option.class.value

* Update worlds/ahit/DeathWishLocations.py

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

* review stuff

* Replace connect_regions with Region.connect

* review stuff

* Remove unnecessary Optional from LocData

* Remove HatType.NONE

* Update worlds/ahit/test/TestActs.py

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

* fix so default tests actually don't run

* Improve performance for death wish rules

* rename test file

* change test imports

* 1000 is probably unnecessary

* a

* change state.count to state.has

* stuff

* starting inventory hats fix

* shouldn't have done this lol

* make ship shape task goal equal to number of tasksanity checks if set to 0

* a

* change act shuffle starting acts + logic updates

* dumb

* option groups + lambda capture cringe + typo

* a

* b

* missing option in groups

* c

* Fix Your Contract Has Expired being placed on first level when it shouldn't

* formatting

* major logic bug fix for death wish

---------

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-06-04 21:15:28 +02:00
Scipio Wright
c4e0b17de3 TUNIC: Add ice grapple logic to get to gauntlet (#3459) 2024-06-04 21:14:29 +02:00
Bryce Wilson
0265f4d809 BizHawkClient: Reset finished_game if ROM changes (#3246) 2024-06-04 14:06:41 +02:00
Chris Wilson
06e65c1dc6 WebHost: weighted-options bugfixes (#3448)
* Fix improper css for word-break on player-options page

* Add default handling to weighted-options types

* Remove random-low/mid/high from Toggle, Choice, and TextChoice,

* Port key sorting for OptionList and OptionSet from player-options to weighted-options

* Ensure Choice and TextChoice values are set properly

* Remove debug line 🤦‍♂️
2024-06-03 18:43:01 -04:00
Exempt-Medic
c7eef13b33 Accounting for name change (#3449) 2024-06-03 16:36:51 +02:00
Star Rauchenberger
fb2c194e37 Lingo: Fix Basement access with THE MASTER (#3231) 2024-06-03 03:51:27 -05:00
Zach Parks
cff7327558 Utils: Fix mistake made with KeyedDefaultDict from #1933 that broke tracker functionality. (#3433) 2024-06-03 03:45:01 -05:00
Scipio Wright
70e9ccb13c TUNIC: Fix plando connections, seed groups, and UT support (#3429) 2024-06-03 03:44:37 -05:00
Exempt-Medic
d9120f0bea WebHost: Allowing options that work on WebHost to be used in presets (#3441) 2024-06-03 03:42:27 -05:00
Remy Jette
424c8b0be9 Pokemon RB: Add an item group for each HM to improve hinting (#3311)
* Pokemon RB: Add an item group for each HM

HMs are suffixed with the name of the move, e.g. "HM02 Fly". If TM
move are randomized, they do not have the move name, e.g. "TM02".

If someone hints for an HM using the just the number, the fuzzy matching
sees "TM02" as closer than "HM02 Fly", and in fact sees it as close
enough to not ask the user to confirm, leading them to waste hint points
on non-progression item that they didn't intend.

Emerald already does this for this reason, adding the same for RB.

* Add the new groups for HMs in the item_table instead
2024-06-03 04:42:15 +02:00
qwint
6432560fe5 Fix Egg_Shop typo in costsanity (#3447) 2024-06-03 04:39:34 +02:00
Emily
dedabad290 APSudoku: take over maintaining hintgame sudoku from bk_sudoku (#3432) 2024-06-02 11:45:46 -05:00
NewSoupVi
e49b1f9fbb The Witness: Automatic Postgame & Disabled Panels Calculation (#2698)
* Refactor postgame code to be more readable

* Change all references to options to strings

* oops

* Fix some outdated code related to yaml-disabled EPs

* Small fixes to short/longbox stuff (thanks Medic)

* comment

* fix duplicate

* Removed triplicate lmfao

* Better comment

* added another 'unfun' postgame consideration

* comment

* more option strings

* oops

* Remove an unnecessary comparison

* another string missed

* New classification changes (Credit: Exempt-Medic)

* Don't need to pass world

* Comments

* Replace it with another magic system because why not at this point :DDDDDD

* oops

* Oops

* Another was missed

* Make events conditions. Disable_Non_Randomized will no longer just 'have all events'

* What the fuck? Has this just always been broken?

* Don't have boolean function with 'not' in the name

* Another useful classification

* slight code refactor

* Funny haha booleans

* This would create a really bad merge error

* I can't believe this actually kind of works

* And here's the punchline. + some bugfixes

* Comment dat code

* Comments galore

* LMAO OOPS

* so nice I did it twice

* debug x2

* Careful

* Add more comments

* That comment is a bit unnecessary now

* Fix overriding region connections

* Correct a comment

* Correct again

* Rename variable

* Idk I guess this is in this branch now

* More tweaking of postgame & comments

* This is commit just exists to fix that grammar error

* I think I can just fucking delete this now???

* Forgot to reset something here

* Delete dead codepath

* Obelisk Keys were getting yote erroneously

* More comments

* Fix duplicate connections

* Oopsington III

* performance improvements & cleanup

* More rules cleanup and performance improvements

* Oh cool I can do this huh

* Okay but this is even more swag tho

* Lazy eval

* remove some implicit checks

* Is this too magical yet

* more guard magic

* Maaaaaaaagiccccccccc

* Laaaaaaaaaaaaaaaazzzzzzyyyyyyyyyyy

* Make it docstring

* Newline bc I like that better

* this is a little spooky lol

* lol

* Wait

* spoO

* Better variable name and comment

* Improved comment again

* better API

* oops I deleted a deepcopy

* lol help

* Help???

* player_regionsns lmao

* Add some comments

* Make doors disabled properly again. I hope this works

* Don't disable lasers

* Omega oops

* Make Floor 2 Exit not exist

* Make a fix that's warps compatible

* I think this was an oversight, I tested a seed and it seems to have the same result

* This is definitely less Violet than before

* Does this feel more violet lol

* Exception if a laser gets disabled, cleanup

* Ruff

* >:(

* consistent utils import

* Make autopostgame more reviewable (hopefully)

* more reviewability

* WitnessRule

* replace another instance of it

* lint

* style

* comment

* found the bug

* Move comment

* Get rid of cache and ugly allow_victory

* comments and lint
2024-06-01 23:11:28 +02:00
Fabian Dill
da33d1576a WebHost: update trackers only if they're visible. (#3407) 2024-06-01 17:07:58 +02:00
Fabian Dill
13bc121c27 Webhost: Sphere Tracker (#3412) 2024-06-01 14:43:11 +02:00
Fabian Dill
bbc79a5b99 LttP: allow Triforce Piece as start inventory item (#3292) 2024-06-01 14:38:45 +02:00
Ishigh1
3cb5452455 Core: Fix auto-fill in the text client when clicking on a hint suggestion (#3267) 2024-06-01 07:32:41 -05:00
Nicholas Saylor
8dbc8d2d41 Installer: Prevent ALTTP Sprite Download from being Interrupted (#3293) 2024-06-01 06:42:02 -05:00
Dinopony
1e205f9d73 Landstalker: Fixed rare generation issues (#3353)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-06-01 06:39:57 -05:00
Exempt-Medic
97c9c5310b PKMN R/B: Fixing Key Items Only + Removed Exp. All (#3420) 2024-06-01 06:35:33 -05:00
Silvris
4e5b6bb3d2 Core: move PlandoConnections and PlandoTexts to the options system (#2904)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
2024-06-01 06:34:41 -05:00
Bryce Wilson
f40b10dc97 Pokemon Emerald: Adjust options (#3278) 2024-06-01 06:14:40 -05:00
NewSoupVi
4cab3b6371 The Witness: Put Treehouse Both Orange Bridges EP on the normal EPs exclusion list (#3308) 2024-06-01 06:13:00 -05:00
Bryce Wilson
67cd32b37c Pokemon Emerald: Use self.player_name (#3384) 2024-06-01 06:12:37 -05:00
Rensen3
91c89604a5 YGO06: prevent multiple players affecting each others procedure patch (#3409) 2024-06-01 06:10:02 -05:00
Louis M
f2587d5d27 Aquatia: Locations name changed due to typo's, grammar, or inconsistencies (#3421) 2024-06-01 06:09:34 -05:00
Exempt-Medic
2a5de8567e Docs: Making option description more readable and accurate (#3426) 2024-06-01 06:07:43 -05:00
Zach Parks
5aa6ad63ca Core: Remove Universally Unique ID Requirements (Per-Game Data Packages) (#1933) 2024-06-01 06:07:13 -05:00
Chris Wilson
f3003ff147 Fix options pages sometimes displaying blank values in form fields (#3364) 2024-05-31 22:41:49 -04:00
Chris Wilson
15e06e1779 Fix TextChoice options sometimes creating a broken YAML (#3390)
* Fix TextChoice options with custom values improperly being included in YAML output

* Update WebHostLib/options.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-05-31 22:41:03 -04:00
275 changed files with 6427 additions and 3165 deletions

View File

@@ -36,10 +36,15 @@ jobs:
run: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python setup.py build_exe --yes python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME" echo "$NAME -> $ZIP_NAME"
@@ -49,12 +54,6 @@ jobs:
Rename-Item "exe.$NAME" Archipelago Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
- name: Build Setup - name: Build Setup
run: | run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
@@ -65,11 +64,38 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name $SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup - name: Store Setup
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.SETUP_NAME }} name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004: build-ubuntu2004:
@@ -110,7 +136,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml - # - copy code above to release.yml -
@@ -118,15 +144,36 @@ jobs:
run: | run: |
source venv/bin/activate source venv/bin/activate
python setup.py build_exe --yes python setup.py build_exe --yes
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.APPIMAGE_NAME }} name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7 retention-days: 7
- name: Store .tar.gz - name: Store .tar.gz
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.TAR_NAME }} name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7 retention-days: 7

54
.github/workflows/ctest.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
# Run CMake / CTest C++ unit tests
name: ctest
on:
push:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
pull_request:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
jobs:
ctest:
runs-on: ${{ matrix.os }}
name: Test C++ ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests
run: |
cd test/cpp
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release
ls
- name: Run tests
run: |
cd test/cpp
ctest --test-dir build/ -C Release --output-on-failure

View File

@@ -69,7 +69,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml - # - code above copied from build.yml -

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml' - '.github/workflows/unittests.yml'
jobs: jobs:
build: unit:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -60,3 +60,32 @@ jobs:
- name: Unittests - name: Unittests
run: | run: |
pytest -n auto pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.11'} # current
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
python test/hosting/__main__.py

2
.gitignore vendored
View File

@@ -62,6 +62,7 @@ Output Logs/
/installdelete.iss /installdelete.iss
/data/user.kv /data/user.kv
/datapackage /datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -177,6 +178,7 @@ dmypy.json
cython_debug/ cython_debug/
# Cython intermediates # Cython intermediates
_speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html

View File

@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = Utils.get_options() options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"] self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
self.locations_array = None self.locations_array = None
if Utils.get_options()["adventure_options"].get("death_link", False): if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True self.set_deathlink = True
async_start(self.get_freeincarnates_used()) async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo": elif cmd == "RoomInfo":
@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved": elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True) auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args") rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import collections
import copy import copy
import logging import logging
import asyncio import asyncio
@@ -8,6 +9,7 @@ import sys
import typing import typing
import time import time
import functools import functools
import warnings
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -173,10 +175,77 @@ class CommonContext:
items_handling: typing.Optional[int] = None items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect want_slot_data: bool = True # should slot_data be retrieved via Connect
# data package class NameLookupDict:
# Contents in flux until connection to server is made, to download correct data for this multiworld. """A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') self.ctx: CommonContext = ctx
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
return len(self._game_store)
def __iter__(self) -> typing.Iterator[str]:
return iter(self._game_store)
def __repr__(self) -> str:
return self._game_store.__repr__()
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
omitted.
"""
if game_name is None:
game_name = self.ctx.game
assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
return self._game_store[game_name][code]
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
omitted.
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
`ctx.game` and use `lookup_in_game` method instead.
"""
if slot is None:
slot = self.ctx.slot
assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
"""Overrides existing lookup tables for a particular game."""
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
self._archipelago_lookup.clear()
self._archipelago_lookup.update(id_to_name_lookup_table)
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
@@ -231,7 +300,7 @@ class CommonContext:
# message box reporting a loss of connection # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -271,6 +340,9 @@ class CommonContext:
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event() self.watcher_event = asyncio.Event()
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.jsontotextparser = JSONtoTextParser(self) self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self) self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package) self.update_data_package(network_data_package)
@@ -425,6 +497,11 @@ class CommonContext:
Returned text is sent, or sending is aborted if None is returned.""" Returned text is sent, or sending is aborted if None is returned."""
return text return text
def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]): def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items(): for permission_name, permission_flag in permissions.items():
try: try:
@@ -486,19 +563,17 @@ class CommonContext:
or remote_checksum != cache_checksum: or remote_checksum != cache_checksum:
needed_updates.add(game) needed_updates.add(game)
else: else:
self.update_game(cached_game) self.update_game(cached_game, game)
if needed_updates: if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates]) await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict): def update_game(self, game_package: dict, game: str):
for item_name, item_id in game_package["item_name_to_id"].items(): self.item_names.update_game(game, game_package["item_name_to_id"])
self.item_names[item_id] = item_name self.location_names.update_game(game, game_package["location_name_to_id"])
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_data_package(self, data_package: dict): def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items(): for game, game_data in data_package["games"].items():
self.update_game(game_data) self.update_game(game_data, game)
def consume_network_data_package(self, data_package: dict): def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package) self.update_data_package(data_package)

View File

@@ -483,15 +483,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if panic_method == "swap": if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=True, swap=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise": elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, swap=False,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory": elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, allow_partial=True, swap=False, allow_partial=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) name="Progression", single_player_placement=multiworld.players == 1)
if progitempool: if progitempool:
for item in progitempool: for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import copy
import logging import logging
import os import os
import random import random
import string import string
import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import Counter
@@ -15,23 +17,16 @@ import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
import copy
import Utils import Utils
import Options import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
from worlds import failed_world_loads
def mystery_argparse(): def mystery_argparse():
options = get_settings() from settings import get_settings
defaults = options.generator settings = get_settings()
defaults = settings.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path, parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
@@ -43,7 +38,7 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler) parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
parser.add_argument('--outputpath', default=options.general_options.output_path, parser.add_argument('--outputpath', default=settings.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
@@ -63,20 +58,23 @@ def mystery_argparse():
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args, options return args
def get_seed_name(random_source) -> str: def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain): def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
if not args: if not args:
args, options = mystery_argparse() args = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed) seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed) random.seed(seed)
seed_name = get_seed_name(random) seed_name = get_seed_name(random)
@@ -145,6 +143,9 @@ def main(args=None, callback=ERmain):
raise Exception(f"No weights found. " raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.") f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)]) erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed erargs.seed = seed
erargs.plando_options = args.plando erargs.plando_options = args.plando
@@ -236,7 +237,7 @@ def main(args=None, callback=ERmain):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f) yaml.dump(important, f)
return callback(erargs, seed) return erargs, seed
def read_weights_yamls(path) -> Tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -361,6 +362,8 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game: if not game:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types: if game in AutoWorldRegister.world_types:
@@ -438,10 +441,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e: except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else: else:
from worlds import AutoWorldRegister
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
@@ -468,6 +474,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
ret.game = get_choice("game", weights) ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types: if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads: if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. " raise Exception(f"No functional world found to handle game {ret.game}. "
@@ -506,35 +513,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if PlandoOptions.items in plando_options: if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", []) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past": if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options) roll_alttp_settings(ret, game_weights)
if PlandoOptions.connections in plando_options:
ret.plando_connections = []
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
return ret return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): def roll_alttp_settings(ret: argparse.Namespace, weights):
ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.sprite_pool = weights.get('sprite_pool', []) ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link") ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights: if 'random_sprite_on_event' in weights:
@@ -562,7 +546,9 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__': if __name__ == '__main__':
import atexit import atexit
confirmation = atexit.register(input, "Press enter to close.") confirmation = atexit.register(input, "Press enter to close.")
multiworld = main() erargs, seed = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
if __debug__: if __debug__:
import gc import gc
import sys import sys

View File

@@ -19,7 +19,7 @@ import sys
import webbrowser import webbrowser
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Sequence, Union, Optional from typing import Callable, Sequence, Union, Optional
import Utils import Utils
import settings import settings
@@ -160,8 +160,12 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe) subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui(): def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.core.window import Window
from kivy.uix.image import AsyncImage from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout from kivy.uix.relativelayout import RelativeLayout
@@ -169,11 +173,8 @@ def run_gui():
base_title: str = "Archipelago Launcher" base_title: str = "Archipelago Launcher"
container: ContainerLayout container: ContainerLayout
grid: GridLayout grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_tools = {c.display_name: c for c in components if c.type == Type.TOOL} _client_layout: Optional[ScrollBox] = None
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None): def __init__(self, ctx=None):
self.title = self.base_title self.title = self.base_title
@@ -181,18 +182,7 @@ def run_gui():
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
super().__init__() super().__init__()
def build(self): def _refresh_components(self) -> None:
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
tool_layout = ScrollBox()
tool_layout.layout.orientation = "vertical"
self.grid.add_widget(tool_layout)
client_layout = ScrollBox()
client_layout.layout.orientation = "vertical"
self.grid.add_widget(client_layout)
def build_button(component: Component) -> Widget: def build_button(component: Component) -> Widget:
""" """
@@ -217,14 +207,49 @@ def run_gui():
return box_layout return box_layout
return button return button
# clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
for (tool, client) in itertools.zip_longest(itertools.chain( for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): _tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
# column 1 # column 1
if tool: if tool:
tool_layout.layout.add_widget(build_button(tool[1])) self._tool_layout.layout.add_widget(build_button(tool[1]))
# column 2 # column 2
if client: if client:
client_layout.layout.add_widget(build_button(client[1])) self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
return self.container return self.container
@@ -235,6 +260,14 @@ def run_gui():
else: else:
launch(get_exe(button.component), button.component.cli) launch(get_exe(button.component), button.component.cli)
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {filename}")
def _stop(self, *largs): def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up. # Closing the window explicitly cleans it up.
@@ -243,10 +276,17 @@ def run_gui():
Launcher().run() Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
global refresh_components
refresh_components = None
def run_component(component: Component, *args): def run_component(component: Component, *args):
if component.func: if component.func:
component.func(*args) component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name: elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args]) subprocess.run([*get_exe(component.script_name), *args])
else: else:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import collections import collections
import contextlib
import copy import copy
import datetime import datetime
import functools import functools
@@ -37,7 +38,7 @@ except ImportError:
import NetUtils import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore SlotType, LocationStore
@@ -168,13 +169,15 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0) generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str] checksums: typing.Dict[str, str]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') item_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')))
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') location_names: typing.Dict[str, typing.Dict[int, str]] = (
collections.defaultdict(lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')))
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]] non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]] spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """ """ each sphere is { player: { location_id, ... } } """
logger: logging.Logger logger: logging.Logger
@@ -229,7 +232,7 @@ class Context:
self.embedded_blacklist = {"host", "port"} self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {} self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds self.auto_save_interval = 60 # in seconds
self.auto_saver_thread = None self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.save_dirty = False self.save_dirty = False
self.tags = ['AP'] self.tags = ['AP']
self.games: typing.Dict[int, str] = {} self.games: typing.Dict[int, str] = {}
@@ -266,19 +269,31 @@ class Context:
for world_name, world in worlds.AutoWorldRegister.world_types.items(): for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist self.non_hintable_names[world_name] = world.hint_blacklist
for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]
def _init_game_data(self): def _init_game_data(self):
for game_name, game_package in self.gamespackage.items(): for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package: if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"] self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items(): for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name self.item_names[game_name][item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items(): for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name self.location_names[game_name][location_id] = location_name
self.all_item_and_group_names[game_name] = \ self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \ self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, [])) set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
archipelago_item_names = self.item_names["Archipelago"]
archipelago_location_names = self.location_names["Archipelago"]
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
# Add Archipelago items and locations to each data package.
self.item_names[game].update(archipelago_item_names)
self.location_names[game].update(archipelago_location_names)
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -783,10 +798,7 @@ async def on_client_connected(ctx: Context, client: Client):
for slot, connected_clients in clients.items(): for slot, connected_clients in clients.items():
if connected_clients: if connected_clients:
name = ctx.player_names[team, slot] name = ctx.player_names[team, slot]
players.append( players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)} games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago") games.add("Archipelago")
await ctx.send_msgs(client, [{ await ctx.send_msgs(client, [{
@@ -801,8 +813,6 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx), 'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost, 'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points, 'location_check_points': ctx.location_check_points,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data 'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data}, in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name, 'seed_name': ctx.seed_name,
@@ -1006,8 +1016,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % ( ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id], team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
ctx.player_names[(team, target_player)], ctx.location_names[location])) ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
info_text = json_format_send_event(new_item, target_player) info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text]) ctx.broadcast_team(team, [info_text])
@@ -1061,8 +1071,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[hint.item]} is " \ f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
f"at {ctx.location_names[hint.location]} " \ f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World" f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance: if hint.entrance:
@@ -1091,28 +1101,6 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
"item": net_item} "item": net_item}
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
class CommandMeta(type): class CommandMeta(type):
def __new__(cls, name, bases, attrs): def __new__(cls, name, bases, attrs):
commands = attrs["commands"] = {} commands = attrs["commands"] = {}
@@ -1364,7 +1352,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1377,7 +1365,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1395,7 +1383,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
names = [self.ctx.location_names[location] for location in locations] game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
if filter_text: if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name if filter_text in location_groups: # location group name
@@ -1420,7 +1409,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
names = [self.ctx.location_names[location] for location in locations] game = self.ctx.slot_info[self.client.slot].game
names = [self.ctx.location_names[game][location] for location in locations]
if filter_text: if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]] location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name if filter_text in location_groups: # location group name
@@ -1501,10 +1491,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
elif input_text.isnumeric(): elif input_text.isnumeric():
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
hint_id = int(input_text) hint_id = int(input_text)
hint_name = self.ctx.item_names[hint_id] \ hint_name = self.ctx.item_names[game][hint_id] \
if not for_location and hint_id in self.ctx.item_names \ if not for_location and hint_id in self.ctx.item_names[game] \
else self.ctx.location_names[hint_id] \ else self.ctx.location_names[game][hint_id] \
if for_location and hint_id in self.ctx.location_names \ if for_location and hint_id in self.ctx.location_names[game] \
else None else None
if hint_name in self.ctx.non_hintable_names[game]: if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1942,8 +1932,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
self.ctx.server.ws_server.close() self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set() self.ctx.exit_event.set()
return True return True
@@ -2301,7 +2289,8 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None): async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown) with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
def inactivity_shutdown(): def inactivity_shutdown():
ctx.server.ws_server.close() ctx.server.ws_server.close()
@@ -2321,7 +2310,8 @@ async def auto_shutdown(ctx, to_cancel=None):
if seconds < 0: if seconds < 0:
inactivity_shutdown() inactivity_shutdown()
else: else:
await asyncio.sleep(seconds) with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":

View File

@@ -198,7 +198,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8", "slateblue": "6D8BE8",
"plum": "AF99EF", "plum": "AF99EF",
"salmon": "FA8072", "salmon": "FA8072",
"white": "FFFFFF" "white": "FFFFFF",
"orange": "FF7700",
} }
def __init__(self, ctx): def __init__(self, ctx):
@@ -247,7 +248,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_item_id(self, node: JSONMessagePart): def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"]) item_id = int(node["text"])
node["text"] = self.ctx.item_names[item_id] node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
return self._handle_item_name(node) return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart): def _handle_location_name(self, node: JSONMessagePart):
@@ -255,8 +256,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node) return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart): def _handle_location_id(self, node: JSONMessagePart):
item_id = int(node["text"]) location_id = int(node["text"])
node["text"] = self.ctx.location_names[item_id] node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
return self._handle_location_name(node) return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart): def _handle_entrance_name(self, node: JSONMessagePart):

View File

@@ -12,6 +12,7 @@ from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from schema import And, Optional, Or, Schema from schema import And, Optional, Or, Schema
from typing_extensions import Self
from Utils import get_fuzzy_results, is_iterable_except_str from Utils import get_fuzzy_results, is_iterable_except_str
@@ -52,8 +53,8 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert ( assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
@@ -125,10 +126,28 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
# can be weighted between selections # can be weighted between selections
supports_weighting = True supports_weighting = True
rich_text_doc: typing.Optional[bool] = None
"""Whether the WebHost should render the Option's docstring as rich text.
If this is True, the Option's docstring is interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, it's rendered to HTML so
that lists, emphasis, and other rich text features are displayed properly.
If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
# filled by AssembleOptions: # filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore # https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]] options: typing.ClassVar[typing.Dict[str, int]]
aliases: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})" return f"{self.__class__.__name__}({self.current_option_name})"
@@ -734,6 +753,12 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values(): elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}") f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring
for key in self.special_range_names:
if key != key.lower():
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
self.value = value self.value = value
@classmethod @classmethod
@@ -896,12 +921,237 @@ class ItemSet(OptionSet):
convert_name_groups = True convert_name_groups = True
class PlandoText(typing.NamedTuple):
at: str
text: typing.List[str]
percentage: int = 100
PlandoTextsFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
]
class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
default = ()
supports_weighting = False
display_name = "Plando Texts"
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.texts & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")
@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts: typing.List[PlandoText] = []
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
given_text = text.get("text", [])
if isinstance(given_text, str):
given_text = [given_text]
texts.append(PlandoText(
at,
given_text,
text.get("percentage", 100)
))
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
cls.verify_keys([text.at for text in texts])
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
@classmethod
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
return str({text.at: " ".join(text.text) for text in value})
def __iter__(self) -> typing.Iterator[PlandoText]:
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)
def __len__(self) -> int:
return self.value.__len__()
class ConnectionsMeta(AssembleOptions):
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
if name != "PlandoConnections":
assert "entrances" in attrs, f"Please define valid entrances for {name}"
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
assert "exits" in attrs, f"Please define valid exits for {name}"
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
if "__doc__" not in attrs:
attrs["__doc__"] = PlandoConnections.__doc__
cls = super().__new__(mcs, name, bases, attrs)
return cls
class PlandoConnection(typing.NamedTuple):
class Direction:
entrance = "entrance"
exit = "exit"
both = "both"
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100
PlandoConFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
]
class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
"""Generic connections plando. Format is:
- entrance: "Entrance Name"
exit: "Exit Name"
direction: "Direction"
percentage: 100
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
display_name = "Plando Connections"
default = ()
supports_weighting = False
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
def __init__(self, value: typing.Iterable[PlandoConnection]):
self.value = list(deepcopy(value))
super(PlandoConnections, self).__init__()
@classmethod
def validate_entrance_name(cls, entrance: str) -> bool:
return entrance.lower() in cls.entrances
@classmethod
def validate_exit_name(cls, exit: str) -> bool:
return exit.lower() in cls.exits
@classmethod
def can_connect(cls, entrance: str, exit: str) -> bool:
"""Checks that a given entrance can connect to a given exit.
By default, this will always return true unless overridden."""
return True
@classmethod
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
used_entrances: typing.List[str] = []
used_exits: typing.List[str] = []
for connection in connections:
entrance = connection.entrance
exit = connection.exit
direction = connection.direction
if direction not in (PlandoConnection.Direction.entrance,
PlandoConnection.Direction.exit,
PlandoConnection.Direction.both):
raise ValueError(f"Unknown direction: {direction}")
if entrance in used_entrances:
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
if not cls.duplicate_exits and exit in used_exits:
raise ValueError(f"Duplicate Exit {exit} not allowed.")
used_entrances.append(entrance)
used_exits.append(exit)
if not cls.validate_entrance_name(entrance):
raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit):
raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
@classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self:
if not isinstance(data, typing.Iterable):
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
value: typing.List[PlandoConnection] = []
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
exit = connection.get("exit", None)
if is_iterable_except_str(exit):
exit = random.choice(sorted(exit))
direction = connection.get("direction", "both")
if not entrance or not exit:
raise Exception("Plando connection must have an entrance and an exit.")
value.append(PlandoConnection(
entrance,
exit,
direction,
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
cls.validate_plando_connections(value)
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.connections & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando connections module is turned off, "
f"so connections for {player_name} will be ignored.")
@classmethod
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
return ", ".join(["%s %s %s" % (connection.entrance,
"<=>" if connection.direction == PlandoConnection.Direction.both else
"<=" if connection.direction == PlandoConnection.Direction.exit else
"=>",
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Accessibility(Choice): class Accessibility(Choice):
"""Set rules for reachability of your items/locations. """Set rules for reachability of your items/locations.
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired. - **Locations:** ensure everything can be reached and acquired.
Minimal: ensure what is needed to reach your goal can be acquired.""" - **Items:** ensure all logically relevant items can be acquired.
- **Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility" display_name = "Accessibility"
rich_text_doc = True
option_locations = 0 option_locations = 0
option_items = 1 option_items = 1
option_minimal = 2 option_minimal = 2
@@ -911,11 +1161,14 @@ class Accessibility(Choice):
class ProgressionBalancing(NamedRange): class ProgressionBalancing(NamedRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck."""
A lower setting means more getting stuck. A higher setting means less getting stuck.
"""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
display_name = "Progression Balancing" display_name = "Progression Balancing"
rich_text_doc = True
special_range_names = { special_range_names = {
"disabled": 0, "disabled": 0,
"normal": 50, "normal": 50,
@@ -980,29 +1233,36 @@ class CommonOptions(metaclass=OptionsMetaProperty):
class LocalItems(ItemSet): class LocalItems(ItemSet):
"""Forces these items to be in their native world.""" """Forces these items to be in their native world."""
display_name = "Local Items" display_name = "Local Items"
rich_text_doc = True
class NonLocalItems(ItemSet): class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world.""" """Forces these items to be outside their native world."""
display_name = "Not Local Items" display_name = "Non-local Items"
rich_text_doc = True
class StartInventory(ItemDict): class StartInventory(ItemDict):
"""Start with these items.""" """Start with these items."""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world. """Start with these items and don't place them in the world.
The game decides what the replacement items will be."""
The game decides what the replacement items will be.
"""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory from Pool" display_name = "Start Inventory from Pool"
rich_text_doc = True
class StartHints(ItemSet): class StartHints(ItemSet):
"""Start with these item's locations prefilled into the !hint command.""" """Start with these item's locations prefilled into the ``!hint`` command."""
display_name = "Start Hints" display_name = "Start Hints"
rich_text_doc = True
class LocationSet(OptionSet): class LocationSet(OptionSet):
@@ -1011,28 +1271,33 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet): class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command""" """Start with these locations and their item prefilled into the ``!hint`` command."""
display_name = "Start Location Hints" display_name = "Start Location Hints"
rich_text_doc = True
class ExcludeLocations(LocationSet): class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item""" """Prevent these locations from having an important item."""
display_name = "Excluded Locations" display_name = "Excluded Locations"
rich_text_doc = True
class PriorityLocations(LocationSet): class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item""" """Prevent these locations from having an unimportant item."""
display_name = "Priority Locations" display_name = "Priority Locations"
rich_text_doc = True
class DeathLink(Toggle): class DeathLink(Toggle):
"""When you die, everyone dies. Of course the reverse is true too.""" """When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link" display_name = "Death Link"
rich_text_doc = True
class ItemLinks(OptionList): class ItemLinks(OptionList):
"""Share part of your item pool with other players.""" """Share part of your item pool with other players."""
display_name = "Item Links" display_name = "Item Links"
rich_text_doc = True
default = [] default = []
schema = Schema([ schema = Schema([
{ {
@@ -1047,7 +1312,8 @@ class ItemLinks(OptionList):
]) ])
@staticmethod @staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set: def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
allow_item_groups: bool = True) -> typing.Set:
pool = set() pool = set()
for item_name in items: for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups): if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
@@ -1098,6 +1364,7 @@ class ItemLinks(OptionList):
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True
default = "" default = ""
visibility = Visibility.none visibility = Visibility.none
@@ -1200,14 +1467,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
return data, notes return data, notes
def yaml_dump_scalar(scalar) -> str:
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
grouped_options = get_option_groups(world) option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f: with open(local_path("data", "options.yaml")) as f:
file_data = f.read() file_data = f.read()
res = Template(file_data).render( res = Template(file_data).render(
option_groups=grouped_options, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
) )

View File

@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = "" toDraw = ""
for i in range(20): for i in range(20):
if i < len(str(ctx.item_names[l.item])): if i < len(str(ctx.item_names.lookup_in_game(l.item))):
toDraw += str(ctx.item_names[l.item])[i] toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
else: else:
break break
f.write(toDraw) f.write(toDraw)

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.4.6" __version__ = "0.5.0"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -458,6 +458,15 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory""" """defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any] default_factory: typing.Callable[[typing.Any], typing.Any]
def __init__(self,
default_factory: typing.Callable[[Any], Any] = None,
seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
**kwargs):
if seq is not None:
super().__init__(default_factory, seq, **kwargs)
else:
super().__init__(default_factory, **kwargs)
def __missing__(self, key): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -544,6 +553,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
f"Archipelago ({__version__}) logging initialized" f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}" f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
) )
@@ -619,6 +629,41 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
) )
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
elif dif > 5:
return picks[0][0], True, "Close Match"
else:
return picks[0][0], False, f"Too many close matches for '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
else:
if picks[0][1] > 90:
return picks[0][0], True, "Only Option Match"
else:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"!{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")

View File

@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
if not os.path.isfile(path): if not os.path.isfile(path):
open(path, 'w').close() open(path, 'w').close()
# Announcing commander unlocks # Announcing commander unlocks
item_name = self.item_names[network_item.item] item_name = self.item_names.lookup_in_game(network_item.item)
if item_name in faction_table.keys(): if item_name in faction_table.keys():
for commander in faction_table[item_name]: for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!") logger.info(f"{commander.name} has been unlocked!")
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
open(print_path, 'w').close() open(print_path, 'w').close()
with open(print_path, 'w') as f: with open(print_path, 'w') as f:
f.write("Received " + f.write("Received " +
self.item_names[network_item.item] + self.item_names.lookup_in_game(network_item.item) +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close() f.close()
@@ -342,7 +342,7 @@ class WargrooveContext(CommonContext):
faction_items = 0 faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received: for network_item in self.items_received:
if self.item_names[network_item.item] in faction_item_names: if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
faction_items += 1 faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0 # Must be an integer larger than 0

View File

@@ -12,6 +12,9 @@ ModuleUpdate.update()
import Utils import Utils
import settings import settings
if typing.TYPE_CHECKING:
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
@@ -19,7 +22,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app(): def get_app() -> "Flask":
from WebHostLib import register, cache, app as raw_app from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db from WebHostLib.models import db
@@ -55,6 +58,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs") base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game) target_path = os.path.join(base_target_path, game)

View File

@@ -5,7 +5,6 @@ from uuid import UUID
from flask import Blueprint, abort, url_for from flask import Blueprint, abort, url_for
import worlds.Files import worlds.Files
from .. import cache
from ..models import Room, Seed from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -49,30 +48,4 @@ def room_info(room: UUID):
} }
@api_endpoints.route('/datapackage') from . import generate, user, datapackage # trigger registration
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -0,0 +1,32 @@
from flask import abort
from Utils import restricted_loads
from WebHostLib import cache
from WebHostLib.models import GameDataPackage
from . import api_endpoints
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage/<string:checksum>')
@cache.memoize(timeout=3600)
def get_datapackage_by_checksum(checksum: str):
package = GameDataPackage.get(checksum=checksum)
if package:
return restricted_loads(package.data)
return abort(404)
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -168,17 +168,28 @@ def get_random_port():
def get_static_server_data() -> dict: def get_static_server_data() -> dict:
import worlds import worlds
data = { data = {
"non_hintable_names": {}, "non_hintable_names": {
"gamespackage": worlds.network_data_package["games"], world_name: world.hint_blacklist
"item_name_groups": {world_name: world.item_name_groups for world_name, world in for world_name, world in worlds.AutoWorldRegister.world_types.items()
worlds.AutoWorldRegister.world_types.items()}, },
"location_name_groups": {world_name: world.location_name_groups for world_name, world in "gamespackage": {
worlds.AutoWorldRegister.world_types.items()}, world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
} }
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data return data
@@ -266,12 +277,15 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.logger.exception("Could not determine port. Likely hosting failure.") ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session: with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task await ctx.shutdown_task
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
if ctx.saving: if ctx.saving:
ctx._save() ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e: except Exception as e:
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
@@ -281,8 +295,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
else: else:
if ctx.saving: if ctx.saving:
ctx._save() ctx._save()
setattr(asyncio.current_task(), "save", None)
finally: finally:
try: try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session): with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer # ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id) room = Room.get(id=room_id)
@@ -294,13 +312,32 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
rooms_shutting_down.put(room_id) rooms_shutting_down.put(room_id)
class Starter(threading.Thread): class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]
def __init__(self):
super().__init__()
self._tasks = []
def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()
def run(self): def run(self):
while 1: while 1:
next_room = rooms_to_run.get(block=True, timeout=None) next_room = rooms_to_run.get(block=True, timeout=None)
asyncio.run_coroutine_threadsafe(start_room(next_room), loop) task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.") logging.info(f"Starting room {next_room} on {name}.")
starter = Starter() starter = Starter()
starter.daemon = True starter.daemon = True
starter.start() starter.start()
loop.run_forever() try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()

View File

@@ -3,6 +3,7 @@ import json
import os import os
from textwrap import dedent from textwrap import dedent
from typing import Dict, Union from typing import Dict, Union
from docutils.core import publish_parts
import yaml import yaml
from flask import redirect, render_template, request, Response from flask import redirect, render_template, request, Response
@@ -66,6 +67,22 @@ def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ") return dedent(text).strip("\n ")
@app.template_filter("rst_to_html")
def filter_rst_to_html(text: str) -> str:
"""Converts reStructuredText (such as a Python docstring) to HTML."""
if text.startswith(" ") or text.startswith("\t"):
text = dedent(text)
elif "\n" in text:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
})['body']
@app.template_test("ordered") @app.template_test("ordered")
def test_ordered(obj): def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence) return isinstance(obj, collections.abc.Sequence)
@@ -76,6 +93,34 @@ def test_ordered(obj):
def option_presets(game: str) -> Response: def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game] world = AutoWorldRegister.world_types[game]
presets = {}
for preset_name, preset in world.web.options_presets.items():
presets[preset_name] = {}
for preset_option_name, preset_option in preset.items():
if preset_option == "random":
presets[preset_name][preset_option_name] = preset_option
continue
option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
assert preset_option in option.special_range_names, \
f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
assert option.name_lookup[option.value] == preset_option, \
f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
else:
# Use the name of the option
presets[preset_name][preset_option_name] = option.current_key
class SetEncoder(json.JSONEncoder): class SetEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
from collections.abc import Set from collections.abc import Set
@@ -83,7 +128,7 @@ def option_presets(game: str) -> Response:
return list(obj) return list(obj)
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
json_data = json.dumps(world.web.options_presets, cls=SetEncoder) json_data = json.dumps(presets, cls=SetEncoder)
response = Response(json_data) response = Response(json_data)
response.headers["Content-Type"] = "application/json" response.headers["Content-Type"] = "application/json"
return response return response
@@ -169,9 +214,9 @@ def generate_yaml(game: str):
else: else:
options[key] = val options[key] = val
# Detect and build ItemDict options from their name pattern
for key, val in options.copy().items(): for key, val in options.copy().items():
key_parts = key.rsplit("||", 2) key_parts = key.rsplit("||", 2)
# Detect and build ItemDict options from their name pattern
if key_parts[-1] == "qty": if key_parts[-1] == "qty":
if key_parts[0] not in options: if key_parts[0] not in options:
options[key_parts[0]] = {} options[key_parts[0]] = {}
@@ -179,10 +224,17 @@ def generate_yaml(game: str):
options[key_parts[0]][key_parts[1]] = int(val) options[key_parts[0]][key_parts[1]] = int(val)
del options[key] del options[key]
# Detect keys which end with -custom, indicating a TextChoice with a possible custom value
elif key_parts[-1].endswith("-custom"):
if val:
options[key_parts[-1][:-7]] = val
del options[key]
# Detect random-* keys and set their options accordingly # Detect random-* keys and set their options accordingly
for key, val in options.copy().items(): for key, val in options.copy().items():
if key.startswith("random-"): if key.startswith("random-"):
options[key.removeprefix("random-")] = "random" options[key[len("random-"):]] = "random"
del options[key] del options[key]
# Error checking # Error checking

View File

@@ -1,9 +1,10 @@
flask>=3.0.0 flask>=3.0.3
werkzeug>=3.0.3
pony>=0.7.17 pony>=0.7.17
waitress>=2.1.2 waitress>=3.0.0
Flask-Caching>=2.1.0 Flask-Caching>=2.3.0
Flask-Compress>=1.14 Flask-Compress>=1.15
Flask-Limiter>=3.5.0 Flask-Limiter>=3.7.0
bokeh>=3.1.1; python_version <= '3.8' bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.3.2; python_version >= '3.9' bokeh>=3.4.1; python_version >= '3.9'
markupsafe>=2.1.3 markupsafe>=2.1.5

View File

@@ -27,7 +27,7 @@ const adjustTableHeight = () => {
* @returns {string} * @returns {string}
*/ */
const secondsToHours = (seconds) => { const secondsToHours = (seconds) => {
let hours = Math.floor(seconds / 3600); let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
}; };
@@ -38,18 +38,18 @@ window.addEventListener('load', () => {
info: false, info: false,
dom: "t", dom: "t",
stateSave: true, stateSave: true,
stateSaveCallback: function(settings, data) { stateSaveCallback: function (settings, data) {
delete data.search; delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data)); localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
}, },
stateLoadCallback: function(settings) { stateLoadCallback: function (settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
}, },
footerCallback: function(tfoot, data, start, end, display) { footerCallback: function (tfoot, data, start, end, display) {
if (tfoot) { if (tfoot) {
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
} }
}, },
columnDefs: [ columnDefs: [
@@ -123,49 +123,64 @@ window.addEventListener('load', () => {
event.preventDefault(); event.preventDefault();
} }
}); });
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker'); const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3; console.log("Target second of refresh: " + target_second);
function getSleepTimeSeconds(){ function getSleepTimeSeconds() {
// -40 % 60 is -40, which is absolutely wrong and should burn // -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60; var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60; return sleepSeconds || 60;
} }
let update_on_view = false;
const update = () => { const update = () => {
const target = $("<div></div>"); if (document.hidden) {
console.log("Updating Tracker..."); console.log("Document reporting as not visible, not updating Tracker...");
target.load(location.href, function (response, status) { update_on_view = true;
if (status === "success") { } else {
target.find(".table").each(function (i, new_table) { update_on_view = false;
const new_trs = $(new_table).find("tbody>tr"); const target = $("<div></div>");
const footer_tr = $(new_table).find("tfoot>tr"); console.log("Updating Tracker...");
const old_table = tables.eq(i); target.load(location.href, function (response, status) {
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); if (status === "success") {
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); target.find(".table").each(function (i, new_table) {
old_table.clear(); const new_trs = $(new_table).find("tbody>tr");
if (footer_tr.length) { const footer_tr = $(new_table).find("tfoot>tr");
$(old_table.table).find("tfoot").html(footer_tr); const old_table = tables.eq(i);
} const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
old_table.rows.add(new_trs); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
old_table.draw(); old_table.clear();
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll); if (footer_tr.length) {
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); $(old_table.table).find("tfoot").html(footer_tr);
}); }
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link")); old_table.rows.add(new_trs);
} else { old_table.draw();
console.log("Failed to connect to Server, in order to update Table Data."); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
console.log(response); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
} });
}) $("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
setTimeout(update, getSleepTimeSeconds()*1000); } else {
console.log("Failed to connect to Server, in order to update Table Data.");
console.log(response);
}
})
}
updater = setTimeout(update, getSleepTimeSeconds() * 1000);
} }
setTimeout(update, getSleepTimeSeconds()*1000); let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
adjustTableHeight(); adjustTableHeight();
tables.draw(); tables.draw();
}); });
window.addEventListener('visibilitychange', () => {
if (!document.hidden && update_on_view) {
console.log("Page became visible, tracker should be refreshed.");
clearTimeout(updater);
update();
}
});
adjustTableHeight(); adjustTableHeight();
}); });

View File

@@ -15,7 +15,7 @@ html {
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
color: #eeffeb; color: #eeffeb;
word-break: break-all; word-break: break-word;
} }
#player-options #player-options-header h1 { #player-options #player-options-header h1 {
margin-bottom: 0; margin-bottom: 0;

View File

@@ -16,7 +16,7 @@ html{
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
color: #eeffeb; color: #eeffeb;
word-break: break-all; word-break: break-word;
#player-options-header{ #player-options-header{
h1{ h1{

View File

@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
*/ */
/* Base styles for the element that has a tooltip */ /* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip { [data-tooltip], .tooltip-container {
position: relative; position: relative;
} }
/* Base styles for the entire tooltip */ /* Base styles for the entire tooltip */
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after { [data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
position: absolute; position: absolute;
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
@@ -39,14 +39,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{ [data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-container:hover .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
word-break: break-word; word-break: break-word;
} }
/** Directional arrow styles */ /** Directional arrow styles */
.tooltip:before, [data-tooltip]:before { [data-tooltip]:before, .tooltip-container:before {
z-index: 10000; z-index: 10000;
border: 6px solid transparent; border: 6px solid transparent;
background: transparent; background: transparent;
@@ -54,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Content styles */ /** Content styles */
.tooltip:after, [data-tooltip]:after { [data-tooltip]:after, .tooltip {
width: 260px; width: 260px;
z-index: 10000; z-index: 10000;
padding: 8px; padding: 8px;
@@ -63,24 +64,26 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
background-color: hsla(0, 0%, 20%, 0.9); background-color: hsla(0, 0%, 20%, 0.9);
color: #fff; color: #fff;
content: attr(data-tooltip); content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }
[data-tooltip]:before, [data-tooltip]:after{ [data-tooltip]:after {
white-space: pre-wrap;
}
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after, [data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
.tooltip-top:before, .tooltip-top:after {
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
} }
[data-tooltip]:before, .tooltip:before, .tooltip-top:before { [data-tooltip]:before, .tooltip-container:before {
margin-left: -6px; margin-left: -6px;
margin-bottom: -12px; margin-bottom: -12px;
border-top-color: #000; border-top-color: #000;
@@ -88,19 +91,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Horizontally align tooltips on the top and bottom */ /** Horizontally align tooltips on the top and bottom */
[data-tooltip]:after, .tooltip:after, .tooltip-top:after { [data-tooltip]:after, .tooltip {
margin-left: -80px; margin-left: -80px;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after, [data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-top:hover:before, .tooltip-top:hover:after { .tooltip-container:hover .tooltip {
-webkit-transform: translateY(-12px); -webkit-transform: translateY(-12px);
-moz-transform: translateY(-12px); -moz-transform: translateY(-12px);
transform: translateY(-12px); transform: translateY(-12px);
} }
/** Tooltips on the left */ /** Tooltips on the left */
.tooltip-left:before, .tooltip-left:after { .tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
right: 100%; right: 100%;
bottom: 50%; bottom: 50%;
left: auto; left: auto;
@@ -115,14 +118,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-left-color: hsla(0, 0%, 20%, 0.9); border-left-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-left:hover:before, .tooltip-left:hover:after { .tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
-webkit-transform: translateX(-12px); -webkit-transform: translateX(-12px);
-moz-transform: translateX(-12px); -moz-transform: translateX(-12px);
transform: translateX(-12px); transform: translateX(-12px);
} }
/** Tooltips on the bottom */ /** Tooltips on the bottom */
.tooltip-bottom:before, .tooltip-bottom:after { .tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
top: 100%; top: 100%;
bottom: auto; bottom: auto;
left: 50%; left: 50%;
@@ -136,14 +139,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-bottom-color: hsla(0, 0%, 20%, 0.9); border-bottom-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-bottom:hover:before, .tooltip-bottom:hover:after { .tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
.tooltip-bottom:hover .tooltip {
-webkit-transform: translateY(12px); -webkit-transform: translateY(12px);
-moz-transform: translateY(12px); -moz-transform: translateY(12px);
transform: translateY(12px); transform: translateY(12px);
} }
/** Tooltips on the right */ /** Tooltips on the right */
.tooltip-right:before, .tooltip-right:after { .tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
bottom: 50%; bottom: 50%;
left: 100%; left: 100%;
} }
@@ -156,7 +160,8 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-right-color: hsla(0, 0%, 20%, 0.9); border-right-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-right:hover:before, .tooltip-right:hover:after { .tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
.tooltip-right:hover .tooltip {
-webkit-transform: translateX(12px); -webkit-transform: translateX(12px);
-moz-transform: translateX(12px); -moz-transform: translateX(12px);
transform: translateX(12px); transform: translateX(12px);
@@ -168,7 +173,16 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Center content vertically for tooltips ont he left and right */ /** Center content vertically for tooltips ont he left and right */
.tooltip-left:after, .tooltip-right:after { [data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
.tooltip-left .tooltip, .tooltip-right .tooltip {
margin-left: 0; margin-left: 0;
margin-bottom: -16px; margin-bottom: -16px;
} }
.tooltip ul, .tooltip ol {
padding-left: 1rem;
}
.tooltip :last-child {
margin-bottom: 0;
}

View File

@@ -24,7 +24,8 @@
<br /> <br />
{% endif %} {% endif %}
{% if room.tracker %} {% if room.tracker %}
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled. This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a>
and a <a href="{{ url_for("get_multiworld_sphere_tracker", tracker=room.tracker) }}">Sphere Tracker</a> enabled.
<br /> <br />
{% endif %} {% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.

View File

@@ -0,0 +1,72 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>Multiworld Sphere Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include "header/dirtHeader.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div class="info">
{% if tracker_data.get_spheres() %}
This tracker lists already found locations by their logical access sphere.
It ignores items that cannot be sent
and will therefore differ from the sphere numbers in the spoiler playthrough.
This tracker will automatically update itself periodically.
{% else %}
This Multiworld has no Sphere data, likely due to being too old, cannot display data.
{% endif %}
</div>
</div>
<div id="tables-container">
{%- for team, players in tracker_data.get_all_players().items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Sphere</th>
{#- Mimicking hint table header for familiarity. #}
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
</tr>
</thead>
<tbody>
{%- for sphere in tracker_data.get_spheres() %}
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>
</tr>
{%- endfor %}
{%- endfor %}
{%- endfor %}
</tbody>
</table>
</div>
{%- endfor -%}
</div>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
{% include "header/dirtHeader.html" %} {% include "header/dirtHeader.html" %}
{% include "multitrackerNavigation.html" %} {% include "multitrackerNavigation.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}"> <div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar"> <div id="tracker-header-bar">
<input placeholder="Search" id="search" /> <input placeholder="Search" id="search" />

View File

@@ -1,180 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/ootTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
</tr>
<tr>
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
<td>
<div class="counted-item">
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
<div class="item-count">{{ hookshot_length }}</div>
</div>
</td>
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
</tr>
<tr>
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
</div>
</td>
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
<td>
<div class="counted-item">
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
<div class="item-count">{{ wallet_size }}</div>
</div>
</td>
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
<div class="item-count">Zelda</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
<div class="item-count">Epona</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
<div class="item-count">Saria</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
<div class="item-count">Sun</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
<div class="item-count">Time</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
<div class="item-count">Storms</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
<div class="item-count">{{ token_count }}</div>
</div>
</td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
<div class="item-count">Min</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
<div class="item-count">Bol</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
<div class="item-count">Ser</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
<div class="item-count">Req</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
<div class="item-count">Noc</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
<div class="item-count">Pre</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
</div>
</td>
</tr>
</table>
<table id="location-table">
<tr>
<td></td>
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
<td class="right-align">Items</td>
</tr>
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td></td>
<td></td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -57,9 +57,9 @@
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}> <select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for key, val in option.special_range_names.items() %} {% for key, val in option.special_range_names.items() %}
{% if option.default == val %} {% if option.default == val %}
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option> <option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
{% else %} {% else %}
<option value="{{ val }}">{{ key }} ({{ val }})</option> <option value="{{ val }}">{{ key|replace("_", " ")|title }} ({{ val }})</option>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<option value="custom" hidden>Custom</option> <option value="custom" hidden>Custom</option>
@@ -111,7 +111,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro ItemDict(option_name, option, world) %} {% macro ItemDict(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
@@ -135,7 +135,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro LocationSet(option_name, option, world) %} {% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %} {% for group_name in world.location_name_groups.keys()|sort %}
@@ -158,7 +158,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro ItemSet(option_name, option, world) %} {% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %} {% for group_name in world.item_name_groups.keys()|sort %}
@@ -196,7 +196,18 @@
{% macro OptionTitle(option_name, option) %} {% macro OptionTitle(option_name, option) %}
<label for="{{ option_name }}"> <label for="{{ option_name }}">
{{ option.display_name|default(option_name) }}: {{ option.display_name|default(option_name) }}:
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span> <span
class="interactive tooltip-container"
{% if not (option.rich_text_doc | default(world.web.rich_text_options_doc, true)) %}
data-tooltip="{{(option.__doc__ | default("Please document me!"))|replace('\n ', '\n')|escape|trim}}"
{% endif %}>
(?)
{% if option.rich_text_doc | default(world.web.rich_text_options_doc, true) %}
<div class="tooltip">
{{ option.__doc__ | default("**Please document me!**") | rst_to_html | safe }}
</div>
{% endif %}
</span>
</label> </label>
{% endmacro %} {% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import 'playerOptions/macros.html' as inputs %} {% import 'playerOptions/macros.html' as inputs with context %}
{% block head %} {% block head %}
<title>{{ world_name }} Options</title> <title>{{ world_name }} Options</title>
@@ -94,16 +94,16 @@
{{ inputs.FreeText(option_name, option) }} {{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }} {{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %} {% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }} {{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %} {% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }} {{ inputs.LocationSet(option_name, option) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %} {% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }} {{ inputs.ItemSet(option_name, option) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %} {% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }} {{ inputs.OptionSet(option_name, option) }}
@@ -134,16 +134,16 @@
{{ inputs.FreeText(option_name, option) }} {{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }} {{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %} {% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }} {{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %} {% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }} {{ inputs.LocationSet(option_name, option) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %} {% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }} {{ inputs.ItemSet(option_name, option) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %} {% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }} {{ inputs.OptionSet(option_name, option) }}

View File

@@ -1,9 +1,9 @@
{% macro Toggle(option_name, option) %} {% macro Toggle(option_name, option) %}
<table> <table>
<tbody> <tbody>
{{ RangeRow(option_name, option, "No", "false") }} {{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
{{ RangeRow(option_name, option, "Yes", "true") }} {{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
{{ RandomRows(option_name, option) }} {{ RandomRow(option_name, option) }}
</tbody> </tbody>
</table> </table>
{% endmacro %} {% endmacro %}
@@ -18,10 +18,14 @@
<tbody> <tbody>
{% for id, name in option.name_lookup.items() %} {% for id, name in option.name_lookup.items() %}
{% if name != 'random' %} {% if name != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }} {% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{{ RandomRows(option_name, option) }} {{ RandomRow(option_name, option) }}
</tbody> </tbody>
</table> </table>
{% endmacro %} {% endmacro %}
@@ -34,16 +38,16 @@
Normal range: {{ option.range_start }} - {{ option.range_end }} Normal range: {{ option.range_start }} - {{ option.range_end }}
{% if option.special_range_names %} {% if option.special_range_names %}
<br /><br /> <br /><br />
The following values has special meaning, and may fall outside the normal range. The following values have special meanings, and may fall outside the normal range.
<ul> <ul>
{% for name, value in option.special_range_names.items() %} {% for name, value in option.special_range_names.items() %}
<li>{{ value }}: {{ name }}</li> <li>{{ value }}: {{ name|replace("_", " ")|title }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<div class="add-option-div"> <div class="add-option-div">
<input type="number" class="range-option-value" data-option="{{ option_name }}" /> <input type="number" class="range-option-value" data-option="{{ option_name }}" />
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button> <button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div> </div>
</div> </div>
<table class="range-rows" data-option="{{ option_name }}"> <table class="range-rows" data-option="{{ option_name }}">
@@ -68,11 +72,13 @@
This option allows custom values only. Please enter your desired values below. This option allows custom values only. Please enter your desired values below.
<div class="custom-value-wrapper"> <div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" /> <input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button> <button type="button" data-option="{{ option_name }}">Add</button>
</div> </div>
<table> <table>
<tbody> <tbody>
<!-- This table to be filled by JS --> {% if option.default %}
{{ RangeRow(option_name, option, option.default, option.default) }}
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -83,17 +89,21 @@
Custom values are also allowed for this option. To create one, enter it into the input box below. Custom values are also allowed for this option. To create one, enter it into the input box below.
<div class="custom-value-wrapper"> <div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" /> <input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button> <button type="button" data-option="{{ option_name }}">Add</button>
</div> </div>
</div> </div>
<table> <table>
<tbody> <tbody>
{% for id, name in option.name_lookup.items() %} {% for id, name in option.name_lookup.items() %}
{% if name != 'random' %} {% if name != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }} {% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{{ RandomRows(option_name, option) }} {{ RandomRow(option_name, option) }}
</tbody> </tbody>
</table> </table>
{% endmacro %} {% endmacro %}
@@ -112,7 +122,7 @@
type="number" type="number"
id="{{ option_name }}-{{ item_name }}-qty" id="{{ option_name }}-{{ item_name }}-qty"
name="{{ option_name }}||{{ item_name }}" name="{{ option_name }}||{{ item_name }}"
value="0" value="{{ option.default[item_name] if item_name in option.default else "0" }}"
/> />
</div> </div>
{% endfor %} {% endfor %}
@@ -121,13 +131,14 @@
{% macro OptionList(option_name, option) %} {% macro OptionList(option_name, option) %}
<div class="list-container"> <div class="list-container">
{% for key in option.valid_keys|sort %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry"> <div class="list-entry">
<input <input
type="checkbox" type="checkbox"
id="{{ option_name }}-{{ key }}" id="{{ option_name }}-{{ key }}"
name="{{ option_name }}||{{ key }}" name="{{ option_name }}||{{ key }}"
value="1" value="1"
checked="{{ "checked" if key in option.default else "" }}"
/> />
<label for="{{ option_name }}-{{ key }}"> <label for="{{ option_name }}-{{ key }}">
{{ key }} {{ key }}
@@ -183,7 +194,7 @@
{% macro OptionSet(option_name, option) %} {% macro OptionSet(option_name, option) %}
<div class="set-container"> <div class="set-container">
{% for key in option.valid_keys|sort %} {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry"> <div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} /> <input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label> <label for="{{ option_name }}-{{ key }}">{{ key }}</label>
@@ -200,13 +211,17 @@
</td> </td>
{% endmacro %} {% endmacro %}
{% macro RandomRow(option_name, option, extra_column=False) %}
{{ RangeRow(option_name, option, "Random", "random") }}
{% endmacro %}
{% macro RandomRows(option_name, option, extra_column=False) %} {% macro RandomRows(option_name, option, extra_column=False) %}
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %} {% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
{{ RangeRow(option_name, option, key, value) }} {{ RangeRow(option_name, option, key, value) }}
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %} {% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}"> <tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
<td class="td-left"> <td class="td-left">
<label for="{{ option_name }}||{{ value }}"> <label for="{{ option_name }}||{{ value }}">
@@ -220,7 +235,7 @@
name="{{ option_name }}||{{ value }}" name="{{ option_name }}||{{ value }}"
min="0" min="0"
max="50" max="50"
{% if option.default == value %} {% if option.default == value or default_override == value %}
value="25" value="25"
{% else %} {% else %}
value="0" value="0"
@@ -229,7 +244,7 @@
</td> </td>
<td class="td-right"> <td class="td-right">
<span id="{{ option_name }}||{{ value }}-value"> <span id="{{ option_name }}||{{ value }}-value">
{% if option.default == value %} {% if option.default == value or default_override == value %}
25 25
{% else %} {% else %}
0 0

View File

@@ -3,8 +3,9 @@ import collections
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
from uuid import UUID from uuid import UUID
from email.utils import parsedate_to_datetime
from flask import render_template from flask import render_template, make_response, Response, request
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second from MultiServer import Context, get_saving_second
@@ -291,47 +292,47 @@ class TrackerData:
return video_feeds return video_feeds
@_cache_results
def get_spheres(self) -> List[List[int]]:
""" each sphere is { player: { location_id, ... } } """
return self._multidata.get("spheres", [])
def _process_if_request_valid(incoming_request, room: Optional[Room]) -> Optional[Response]:
if not room:
abort(404)
if_modified = incoming_request.headers.get("If-Modified-Since", None)
if if_modified:
if_modified = parsedate_to_datetime(if_modified)
# if_modified has less precision than last_activity, so we bring them to same precision
if if_modified >= room.last_activity.replace(microsecond=0):
return make_response("", 304)
@app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>") @app.route("/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> str: def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool = False) -> Response:
key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}" key = f"{tracker}_{tracked_team}_{tracked_player}_{generic}"
tracker_page = cache.get(key) response: Optional[Response] = cache.get(key)
if tracker_page: if response:
return tracker_page return response
timeout, tracker_page = get_timeout_and_tracker(tracker, tracked_team, tracked_player, generic)
cache.set(key, tracker_page, timeout)
return tracker_page
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> str:
return get_player_tracker(tracker, tracked_team, tracked_player, True)
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
@app.route("/tracker/<suuid:tracker>/<game>")
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
def get_multiworld_tracker(tracker: UUID, game: str):
# Room must exist. # Room must exist.
room = Room.get(tracker=tracker) room = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room) response = _process_if_request_valid(request, room)
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys()) if response:
if game not in _multiworld_trackers: return response
return render_generic_multiworld_tracker(tracker_data, enabled_trackers)
return _multiworld_trackers[game](tracker_data, enabled_trackers) timeout, last_modified, tracker_page = get_timeout_and_player_tracker(room, tracked_team, tracked_player, generic)
response = make_response(tracker_page)
response.last_modified = last_modified
cache.set(key, response, timeout)
return response
def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: int, generic: bool) -> Tuple[int, str]: def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player: int, generic: bool)\
# Room must exist. -> Tuple[int, datetime.datetime, str]:
room = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room) tracker_data = TrackerData(room)
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists. # Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
@@ -341,7 +342,48 @@ def get_timeout_and_tracker(tracker: UUID, tracked_team: int, tracked_player: in
else: else:
tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player) tracker = render_generic_tracker(tracker_data, tracked_team, tracked_player)
return (tracker_data.get_room_saving_second() - datetime.datetime.now().second) % 60 or 60, tracker return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
@app.route("/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>")
def get_generic_game_tracker(tracker: UUID, tracked_team: int, tracked_player: int) -> Response:
return get_player_tracker(tracker, tracked_team, tracked_player, True)
@app.route("/tracker/<suuid:tracker>", defaults={"game": "Generic"})
@app.route("/tracker/<suuid:tracker>/<game>")
def get_multiworld_tracker(tracker: UUID, game: str) -> Response:
key = f"{tracker}_{game}"
response: Optional[Response] = cache.get(key)
if response:
return response
# Room must exist.
room = Room.get(tracker=tracker)
response = _process_if_request_valid(request, room)
if response:
return response
timeout, last_modified, tracker_page = get_timeout_and_multiworld_tracker(room, game)
response = make_response(tracker_page)
response.last_modified = last_modified
cache.set(key, response, timeout)
return response
def get_timeout_and_multiworld_tracker(room: Room, game: str)\
-> Tuple[int, datetime.datetime, str]:
tracker_data = TrackerData(room)
enabled_trackers = list(get_enabled_multiworld_trackers(room).keys())
if game in _multiworld_trackers:
tracker = _multiworld_trackers[game](tracker_data, enabled_trackers)
else:
tracker = render_generic_multiworld_tracker(tracker_data, enabled_trackers)
return ((tracker_data.get_room_saving_second() - datetime.datetime.now().second)
% TRACKER_CACHE_TIMEOUT_IN_SECONDS or TRACKER_CACHE_TIMEOUT_IN_SECONDS, room.last_activity, tracker)
def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]: def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
@@ -411,9 +453,30 @@ def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_tracker
videos=tracker_data.get_room_videos(), videos=tracker_data.get_room_videos(),
item_id_to_name=tracker_data.item_id_to_name, item_id_to_name=tracker_data.item_id_to_name,
location_id_to_name=tracker_data.location_id_to_name, location_id_to_name=tracker_data.location_id_to_name,
saving_second=tracker_data.get_room_saving_second(),
) )
def render_generic_multiworld_sphere_tracker(tracker_data: TrackerData) -> str:
return render_template(
"multispheretracker.html",
room=tracker_data.room,
tracker_data=tracker_data,
)
@app.route("/sphere_tracker/<suuid:tracker>")
@cache.memoize(timeout=TRACKER_CACHE_TIMEOUT_IN_SECONDS)
def get_multiworld_sphere_tracker(tracker: UUID):
# Room must exist.
room = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
return render_generic_multiworld_sphere_tracker(tracker_data)
# TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to # TODO: This is a temporary solution until a proper Tracker API can be implemented for tracker templates and data to
# live in their respective world folders. # live in their respective world folders.

View File

@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
def reconcile_shops(ctx: ZeldaContext): def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations] checked_location_names = [ctx.location_names.lookup_in_game(location) for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location] shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop] left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop] middle_slots = [shop for shop in shops if "Middle" in shop]
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
locations_checked = [] locations_checked = []
location = None location = None
for location in ctx.missing_locations: for location in ctx.missing_locations:
location_name = ctx.location_names[location] location_name = ctx.location_names.lookup_in_game(location)
if location_name in Locations.overworld_locations and zone == "overworld": if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]] status = locations_array[Locations.major_location_offsets[location_name]]

View File

@@ -1,5 +1,6 @@
#cython: language_level=3 #cython: language_level=3
#distutils: language = c++ #distutils: language = c
#distutils: depends = intset.h
""" """
Provides faster implementation of some core parts. Provides faster implementation of some core parts.
@@ -13,7 +14,6 @@ from cpython cimport PyObject
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
from cymem.cymem cimport Pool from cymem.cymem cimport Pool
from libc.stdint cimport int64_t, uint32_t from libc.stdint cimport int64_t, uint32_t
from libcpp.set cimport set as std_set
from collections import defaultdict from collections import defaultdict
cdef extern from *: cdef extern from *:
@@ -31,6 +31,27 @@ ctypedef int64_t ap_id_t
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
# configure INTSET for player
cdef extern from *:
"""
#define INTSET_NAME ap_player_set
#define INTSET_TYPE uint32_t // has to match ap_player_t
"""
# create INTSET for player
cdef extern from "intset.h":
"""
#undef INTSET_NAME
#undef INTSET_TYPE
"""
ctypedef struct ap_player_set:
pass
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
void ap_player_set_free(ap_player_set* set) nogil
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
cdef struct LocationEntry: cdef struct LocationEntry:
# layout is so that # layout is so that
@@ -185,7 +206,7 @@ cdef class LocationStore:
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]: def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
cdef ap_id_t item = seeked_item_id cdef ap_id_t item = seeked_item_id
cdef ap_player_t receiver cdef ap_player_t receiver
cdef std_set[ap_player_t] receivers cdef ap_player_set* receivers
cdef size_t slot_count = len(slots) cdef size_t slot_count = len(slots)
if slot_count == 1: if slot_count == 1:
# specialized implementation for single slot # specialized implementation for single slot
@@ -197,13 +218,20 @@ cdef class LocationStore:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
elif slot_count: elif slot_count:
# generic implementation with lookup in set # generic implementation with lookup in set
for receiver in slots: receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
receivers.insert(receiver) if not receivers:
with nogil: raise MemoryError()
for entry in self.entries[:self.entry_count]: try:
if entry.item == item and receivers.count(entry.receiver): for receiver in slots:
with gil: if not ap_player_set_add(receivers, receiver):
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags raise MemoryError()
with nogil:
for entry in self.entries[:self.entry_count]:
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
with gil:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
finally:
ap_player_set_free(receivers)
def get_for_player(self, slot: int) -> Dict[int, Set[int]]: def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
cdef ap_player_t receiver = slot cdef ap_player_t receiver = slot

View File

@@ -1,8 +1,10 @@
# This file is required to get pyximport to work with C++. # This file is used when doing pyximport
# Switching from std::set to a pure C implementation is still on the table to simplify everything. import os
def make_ext(modname, pyxfilename): def make_ext(modname, pyxfilename):
from distutils.extension import Extension from distutils.extension import Extension
return Extension(name=modname, return Extension(name=modname,
sources=[pyxfilename], sources=[pyxfilename],
language='c++') depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c")

View File

@@ -13,6 +13,7 @@
plum: "AF99EF" # typically progression item plum: "AF99EF" # typically progression item
salmon: "FA8072" # typically trap item salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo
<Label>: <Label>:
color: "FFFFFF" color: "FFFFFF"
<TabbedPanel>: <TabbedPanel>:

View File

@@ -68,21 +68,21 @@ requires:
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %} {%- if option.name_lookup[option.default] not in option.options %}
{{ option.default }}: 50 {{ yaml_dump(option.default) }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %} {%- elif option.default is string %}
{{ option.default }}: 50 {{ yaml_dump(option.default) }}: 50
{%- elif option.default is iterable and option.default is not mapping %} {%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }} {{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }} {{ yaml_dump(option.default) | indent(4, first=false) }}
{%- endif -%} {%- endif -%}
{{ "\n" }} {{ "\n" }}
{%- endfor %} {%- endfor %}

View File

@@ -15,15 +15,15 @@
# A Link to the Past # A Link to the Past
/worlds/alttp/ @Berserker66 /worlds/alttp/ @Berserker66
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria # Aquaria
/worlds/aquaria/ @tioui /worlds/aquaria/ @tioui
# ArchipIDLE # ArchipIDLE
/worlds/archipidle/ @LegendaryLinux /worlds/archipidle/ @LegendaryLinux
# Sudoku (BK Sudoku)
/worlds/bk_sudoku/ @Jarno458
# Blasphemous # Blasphemous
/worlds/blasphemous/ @TRPG0 /worlds/blasphemous/ @TRPG0
@@ -70,7 +70,7 @@
/worlds/heretic/ @Daivuk /worlds/heretic/ @Daivuk
# Hollow Knight # Hollow Knight
/worlds/hk/ @BadMagic100 @ThePhar /worlds/hk/ @BadMagic100 @qwint
# Hylics 2 # Hylics 2
/worlds/hylics2/ @TRPG0 /worlds/hylics2/ @TRPG0
@@ -87,9 +87,6 @@
# Lingo # Lingo
/worlds/lingo/ @hatkirby /worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @zig-for
# Lufia II Ancient Cave # Lufia II Ancient Cave
/worlds/lufia2ac/ @el-u /worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u /worlds/lufia2ac/docs/ @wordfcuk @el-u
@@ -218,6 +215,8 @@
# Final Fantasy (1) # Final Fantasy (1)
# /worlds/ff1/ # /worlds/ff1/
# Links Awakening DX
# /worlds/ladx/
## Disabled Unmaintained Worlds ## Disabled Unmaintained Worlds

View File

@@ -53,7 +53,7 @@ Example:
``` ```
## (Server -> Client) ## (Server -> Client)
These packets are are sent from the multiworld server to the client. They are not messages which the server accepts. These packets are sent from the multiworld server to the client. They are not messages which the server accepts.
* [RoomInfo](#RoomInfo) * [RoomInfo](#RoomInfo)
* [ConnectionRefused](#ConnectionRefused) * [ConnectionRefused](#ConnectionRefused)
* [Connected](#Connected) * [Connected](#Connected)
@@ -80,7 +80,6 @@ Sent to clients when they connect to an Archipelago server.
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. | | hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. | | location_check_points | int | The amount of hint points you receive per item/location check completed. |
| games | list\[str\] | List of games present in this multiworld. | | games | list\[str\] | List of games present in this multiworld. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** |
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. | | datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
| seed_name | str | Uniquely identifying name of this generation | | seed_name | str | Uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. | | time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
@@ -500,9 +499,9 @@ In JSON this may look like:
{"item": 3, "location": 3, "player": 3, "flags": 0} {"item": 3, "location": 3, "player": 3, "flags": 0}
] ]
``` ```
`item` is the item id of the item. Item ids are in the range of ± 2<sup>53</sup>-1. `item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`location` is the location id of the item inside the world. Location ids are in the range of ± 2<sup>53</sup>-1. `location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item `player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
@@ -646,15 +645,47 @@ class Hint(typing.NamedTuple):
``` ```
### Data Package Contents ### Data Package Contents
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings. A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago
server most easily and not maintain their own mappings. Some contents include:
We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different version. A special case is datapackage version 0, where it is expected the package is custom and should not be cached. - Name to ID mappings for items and locations.
- A checksum of each game's data package for clients to tell if a cached package is invalid.
Note: We encourage clients to cache the data package they receive on disk, or otherwise not tied to a session. You will know
* Any ID is unique to its type across AP: Item 56 only exists once and Location 56 only exists once. when your cache is outdated if the [RoomInfo](#RoomInfo) packet or the datapackage itself denote a different checksum
* Any Name is unique to its type across its own Game only: Single Arrow can exist in two games. than any locally cached ones.
* The IDs from the game "Archipelago" may be used in any other game.
Especially Location ID -1: Cheat Console and -2: Server (typically Remote Start Inventory) **Important Notes about IDs and Names**:
* IDs ≤ 0 are reserved for "Archipelago" and should not be used by other world implementations.
* The IDs from the game "Archipelago" (in `worlds/generic`) may be used in any world.
* Especially Location ID `-1`: `Cheat Console` and `-2`: `Server` (typically Remote Start Inventory)
* Any names and IDs are only unique in its own world data package, but different games may reuse these names or IDs.
* At runtime, you will need to look up the game of the player to know which item or location ID/Name to lookup in the
data package. This can be easily achieved by reviewing the `slot_info` for a particular player ID prior to lookup.
* For example, a data package like this is valid (Some properties such as `checksum` were omitted):
```json
{
"games": {
"Game A": {
"location_name_to_id": {
"Boss Chest": 40
},
"item_name_to_id": {
"Item X": 12
}
},
"Game B": {
"location_name_to_id": {
"Minigame Prize": 40
},
"item_name_to_id": {
"Item X": 40
}
}
}
}
```
#### Contents #### Contents
| Name | Type | Notes | | Name | Type | Notes |
@@ -668,7 +699,6 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------| |---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. | | item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. | | location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
| version | int | Version number of this game's data. Deprecated. Used by older clients to request an updated datapackage if cache is outdated. |
| checksum | str | A checksum hash of this game's data. | | checksum | str | A checksum hash of this game's data. |
### Tags ### Tags

View File

@@ -85,18 +85,74 @@ class ExampleWorld(World):
options: ExampleGameOptions options: ExampleGameOptions
``` ```
### Option Documentation
Options' [docstrings] are used as their user-facing documentation. They're displayed on the WebHost setup page when a
user hovers over the yellow "(?)" icon, and included in the YAML templates generated for each game.
[docstrings]: /docs/world%20api.md#docstrings
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
default for backwards compatibility, world authors are encouraged to write their Option documentation as
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
[reStructuredText]: https://docutils.sourceforge.io/rst.html
```python
from worlds.AutoWorld import WebWorld
class ExampleWebWorld(WebWorld):
# Render all this world's options as rich text.
rich_text_options_doc = True
```
You can set a single option to use rich or plain text by setting
`Option.rich_text_doc`.
```python
from Options import Toggle, Range, Choice, PerGameCommonOptions
class Difficulty(Choice):
"""Sets overall game difficulty.
- **Easy:** All enemies die in one hit.
- **Normal:** Enemies and the player both have normal health bars.
- **Hard:** The player dies in one hit."""
display_name = "Difficulty"
rich_text_doc = True
option_easy = 0
option_normal = 1
option_hard = 2
default = 1
```
### Option Groups ### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options" by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
group. with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
appear on the WebHost, with the grouping being collapsed when this is `True`.
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
removed from this group.
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
those names, letting you add options to them and change whether they start collapsed. The "Item &
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
be first, regardless of where it is in your list.
```python ```python
from worlds.AutoWorld import WebWorld from worlds.AutoWorld import WebWorld
from Options import OptionGroup from Options import OptionGroup
from . import Options
class MyWorldWeb(WebWorld): class MyWorldWeb(WebWorld):
option_groups = [ option_groups = [
OptionGroup('Color Options', [ OptionGroup("Color Options", [
Options.ColorblindMode, Options.ColorblindMode,
Options.FlashReduction, Options.FlashReduction,
Options.UIColors, Options.UIColors,
@@ -120,7 +176,8 @@ or if I need a boolean object, such as in my slot_data I can access it as:
start_with_sword = bool(self.options.starting_sword.value) start_with_sword = bool(self.options.starting_sword.value)
``` ```
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes, All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
strings that match the option attributes after "option_" is stripped, and the attributes themselves. strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
```python ```python
# options.py # options.py
class Logic(Choice): class Logic(Choice):
@@ -132,6 +189,12 @@ class Logic(Choice):
alias_extra_hard = 2 alias_extra_hard = 2
crazy = 4 # won't be listed as an option and only exists as an attribute on the class crazy = 4 # won't be listed as an option and only exists as an attribute on the class
class Weapon(Choice):
option_none = 0
option_sword = 1
option_bow = 2
option_hammer = 3
# __init__.py # __init__.py
from .options import Logic from .options import Logic
@@ -145,6 +208,16 @@ elif self.options.logic == Logic.option_extreme:
do_extreme_things() do_extreme_things()
elif self.options.logic == "crazy": elif self.options.logic == "crazy":
do_insane_things() do_insane_things()
# check if the current option is in a collection of integers using the class attributes
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
do_stuff()
# in order to make a set of strings work, we have to compare against current_key
elif self.options.weapon.current_key in {"none", "hammer"}:
do_something_else()
# though it's usually better to just use a tuple instead
elif self.options.weapon in ("none", "hammer"):
do_something_else()
``` ```
## Generic Option Classes ## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different These options are generically available to every game automatically, but can be overridden for slightly different

View File

@@ -56,6 +56,12 @@ webhost:
* `options_page` can be changed to a link instead of an AP-generated options page. * `options_page` can be changed to a link instead of an AP-generated options page.
* `rich_text_options_doc` controls whether [Option documentation] uses plain text (`False`) or rich text (`True`). It
defaults to `False`, but world authors are encouraged to set it to `True` for nicer-looking documentation that looks
good on both the WebHost and the YAML template.
[Option documentation]: /docs/options%20api.md#option-documentation
* `theme` to be used for your game-specific AP pages. Available themes: * `theme` to be used for your game-specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone | | dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |

View File

@@ -75,7 +75,7 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
[Run] [Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
@@ -87,7 +87,14 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe" Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe" Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*" Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*" Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss" #include "installdelete.iss"
@@ -209,6 +216,11 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";

135
intset.h Normal file
View File

@@ -0,0 +1,135 @@
/* A specialized unordered_set implementation for literals, where bucket_count
* is defined at initialization rather than increased automatically.
*/
#include <stddef.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#ifndef INTSET_NAME
#error "Please #define INTSET_NAME ... before including intset.h"
#endif
#ifndef INTSET_TYPE
#error "Please #define INTSET_TYPE ... before including intset.h"
#endif
/* macros to generate unique names from INTSET_NAME */
#ifndef INTSET_CONCAT
#define INTSET_CONCAT_(a, b) a ## b
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
#endif
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
#if defined(_MSC_VER)
#pragma warning(push)
#pragma warning(disable : 4200)
#endif
typedef struct {
size_t count;
union INTSET_UNION {
INTSET_TYPE val;
INTSET_TYPE *data;
} v;
} INTSET_BUCKET;
typedef struct {
size_t bucket_count;
INTSET_BUCKET buckets[];
} INTSET_NAME;
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
{
size_t i, size;
INTSET_NAME *set;
if (buckets < 1)
buckets = 1;
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
return NULL;
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
set = (INTSET_NAME*)malloc(size);
if (!set)
return NULL;
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
for (i = 0; i < buckets; i++) {
set->buckets[i].count = 0;
}
set->bucket_count = buckets;
return set;
}
static void INTSET_FUNC(free)(INTSET_NAME *set)
{
size_t i;
if (!set)
return;
for (i = 0; i < set->bucket_count; i++) {
if (set->buckets[i].count > 1)
free(set->buckets[i].v.data);
}
free(set);
}
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
{
size_t i;
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 1)
return bucket->v.val == val;
for (i = 0; i < bucket->count; ++i) {
if (bucket->v.data[i] == val)
return true;
}
return false;
}
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
{
INTSET_BUCKET* bucket;
if (INTSET_FUNC(contains)(set, val))
return true; /* ok */
bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 0) {
bucket->v.val = val;
bucket->count = 1;
} else if (bucket->count == 1) {
INTSET_TYPE old = bucket->v.val;
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
if (!bucket->v.data) {
bucket->v.val = old;
return false; /* error */
}
bucket->v.data[0] = old;
bucket->v.data[1] = val;
bucket->count = 2;
} else {
size_t new_bucket_size;
INTSET_TYPE* new_bucket_data;
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
if (!new_bucket_data)
return false; /* error */
bucket->v.data = new_bucket_data;
bucket->v.data[bucket->count++] = val;
}
return true; /* success */
}
#if defined(_MSC_VER)
#pragma warning(pop)
#endif
#undef INTSET_FUNC
#undef INTSET_BUCKET
#undef INTSET_UNION

92
kvui.py
View File

@@ -3,6 +3,7 @@ import logging
import sys import sys
import typing import typing
import re import re
from collections import deque
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes
@@ -64,7 +65,7 @@ from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from Utils import async_start from Utils import async_start, get_input_text_from_response
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
import CommonClient import CommonClient
@@ -285,16 +286,10 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
temp = MarkupLabel(text=self.text).markup temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
cmdinput = App.get_running_app().textinput cmdinput = App.get_running_app().textinput
if not cmdinput.text and " did you mean " in text: if not cmdinput.text:
for question in ("Didn't find something that closely matches, did you mean ", input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
"Too many close matches, did you mean "): if input_text is not None:
if text.startswith(question): cmdinput.text = input_text
name = Utils.get_text_between(text, question,
"? (")
cmdinput.text = f"!{App.get_running_app().last_autofillable_command} {name}"
break
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text.replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")) Clipboard.copy(text.replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch) return self.parent.select_with_touch(self.index, touch)
@@ -386,6 +381,57 @@ class ConnectBarTextInput(TextInput):
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(TextInput):
MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None:
self._command_history_index = -1
if is_command_input(new_entry):
self._command_history.appendleft(new_entry)
def keyboard_on_key_down(
self,
window,
keycode: typing.Tuple[int, str],
text: typing.Optional[str],
modifiers: typing.List[str]
) -> bool:
"""
:param window: The kivy window object
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
"""
if keycode[1] == 'up':
self._change_to_history_text_if_available(self._command_history_index + 1)
return True
if keycode[1] == 'down':
self._change_to_history_text_if_available(self._command_history_index - 1)
return True
return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1:
return
if new_index >= len(self._command_history):
return
self._command_history_index = new_index
if new_index == -1:
self.text = ""
return
self.text = self._command_history[self._command_history_index]
class MessageBox(Popup): class MessageBox(Popup):
class MessageBoxLabel(Label): class MessageBoxLabel(Label):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -421,7 +467,7 @@ class GameManager(App):
self.commandprocessor = ctx.command_processor(ctx) self.commandprocessor = ctx.command_processor(ctx)
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx) self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
self.log_panels = {} self.log_panels: typing.Dict[str, Widget] = {}
# keep track of last used command to autofill on click # keep track of last used command to autofill on click
self.last_autofillable_command = "hint" self.last_autofillable_command = "hint"
@@ -505,7 +551,7 @@ class GameManager(App):
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None) info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action) info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button) bottom_layout.add_widget(info_button)
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False) self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message) self.textinput.bind(on_text_validate=self.on_message)
self.textinput.text_validate_unfocus = False self.textinput.text_validate_unfocus = False
bottom_layout.add_widget(self.textinput) bottom_layout.add_widget(self.textinput)
@@ -563,14 +609,18 @@ class GameManager(App):
self.ctx.exit_event.set() self.ctx.exit_event.set()
def on_message(self, textinput: TextInput): def on_message(self, textinput: CommandPromptTextInput):
try: try:
input_text = textinput.text.strip() input_text = textinput.text.strip()
textinput.text = "" textinput.text = ""
textinput.update_history(input_text)
if self.ctx.input_requests > 0: if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1 self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text) self.ctx.input_queue.put_nowait(input_text)
elif is_command_input(input_text):
self.ctx.on_ui_command(input_text)
self.commandprocessor(input_text)
elif input_text: elif input_text:
self.commandprocessor(input_text) self.commandprocessor(input_text)
@@ -683,10 +733,18 @@ class HintLog(RecycleView):
for hint in hints: for hint in hints:
data.append({ data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})}, "receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node( "item": {"text": self.parser.handle_node({
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})}, "type": "item_id",
"text": hint["item"],
"flags": hint["item_flags"],
"player": hint["receiving_player"],
})},
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})}, "finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})}, "location": {"text": self.parser.handle_node({
"type": "location_id",
"text": hint["location"],
"player": hint["finding_player"],
})},
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text", "entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"] "color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})}, if hint["entrance"] else "Vanilla"})},

View File

@@ -2,13 +2,13 @@ colorama>=0.4.6
websockets>=12.0 websockets>=12.0
PyYAML>=6.0.1 PyYAML>=6.0.1
jellyfish>=1.0.3 jellyfish>=1.0.3
jinja2>=3.1.3 jinja2>=3.1.4
schema>=0.7.5 schema>=0.7.7
kivy>=2.3.0 kivy>=2.3.0
bsdiff4>=1.2.4 bsdiff4>=1.2.4
platformdirs>=4.1.0 platformdirs>=4.2.2
certifi>=2023.11.17 certifi>=2024.6.2
cython>=3.0.8 cython>=3.0.10
cymem>=2.0.8 cymem>=2.0.8
orjson>=3.9.10 orjson>=3.10.3
typing_extensions>=4.7.0 typing_extensions>=4.12.1

View File

@@ -190,7 +190,7 @@ if is_windows:
c = next(component for component in components if component.script_name == "Launcher") c = next(component for component in components if component.script_name == "Launcher")
exes.append(cx_Freeze.Executable( exes.append(cx_Freeze.Executable(
script=f"{c.script_name}.py", script=f"{c.script_name}.py",
target_name=f"{c.frozen_name}(DEBUG).exe", target_name=f"{c.frozen_name}Debug.exe",
icon=resolve_icon(c.icon), icon=resolve_icon(c.icon),
)) ))

View File

@@ -329,7 +329,7 @@ class WorldTestBase(unittest.TestCase):
for n in range(len(locations) - 1, -1, -1): for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state): if locations[n].can_reach(state):
sphere.append(locations.pop(n)) sphere.append(locations.pop(n))
self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal", self.assertTrue(sphere or self.multiworld.worlds[1].options.accessibility == "minimal",
f"Unreachable locations: {locations}") f"Unreachable locations: {locations}")
if not sphere: if not sphere:
break break

49
test/cpp/CMakeLists.txt Normal file
View File

@@ -0,0 +1,49 @@
cmake_minimum_required(VERSION 3.5)
project(ap-cpp-tests)
enable_testing()
find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc
add_compile_options(-fanalyzer -Werror)
# disable stuff that gets triggered by googletest
add_compile_options(-Wno-analyzer-malloc-leak)
# enable asan for gcc
add_compile_options(-fsanitize=address)
add_link_options(-fsanitize=address)
endif ()
add_executable(test_default)
target_include_directories(test_default
PRIVATE
${GTEST_INCLUDE_DIRS}
)
target_link_libraries(test_default
${GTEST_BOTH_LIBRARIES}
)
add_test(
NAME test_default
COMMAND test_default
)
set_property(
TEST test_default
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
)
file(GLOB ITEMS *)
foreach(item ${ITEMS})
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
message(${item})
add_subdirectory(${item})
endif()
endforeach()

32
test/cpp/README.md Normal file
View File

@@ -0,0 +1,32 @@
# C++ tests
Test framework for C and C++ code in AP.
## Adding a Test
### GoogleTest
Adding GoogleTests is as simple as creating a directory with
* one or more `test_*.cpp` files that define tests using
[GoogleTest API](https://google.github.io/googletest/)
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
### CTest
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
## Running Tests
* Install [CMake](https://cmake.org/).
* Build and/or install GoogleTest and make sure
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
* Enter the directory with the top-most `CMakeLists.txt` and run
```sh
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release && \
ctest --test-dir build/ -C Release --output-on-failure
```

View File

@@ -0,0 +1,4 @@
target_sources(test_default
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
)

View File

@@ -0,0 +1,105 @@
#include <limits>
#include <cstdint>
#include <gtest/gtest.h>
// uint32Set
#define INTSET_NAME uint32Set
#define INTSET_TYPE uint32_t
#include "../../../intset.h"
#undef INTSET_NAME
#undef INTSET_TYPE
// int64Set
#define INTSET_NAME int64Set
#define INTSET_TYPE int64_t
#include "../../../intset.h"
TEST(IntsetTest, ZeroBuckets)
{
// trying to allocate with zero buckets has to either fail or be functioning
uint32Set *set = uint32Set_new(0);
if (!set)
return; // failed -> OK
EXPECT_FALSE(uint32Set_contains(set, 1));
EXPECT_TRUE(uint32Set_add(set, 1));
EXPECT_TRUE(uint32Set_contains(set, 1));
uint32Set_free(set);
}
TEST(IntsetTest, Duplicate)
{
// adding the same number again can't fail
uint32Set *set = uint32Set_new(2);
ASSERT_TRUE(set);
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
uint32Set_free(set);
}
TEST(IntsetTest, SetAllocFailure)
{
// try to allocate 100TB of RAM, should fail and return NULL
if (sizeof(size_t) < 8)
GTEST_SKIP() << "Alloc error not testable on 32bit";
int64Set *set = int64Set_new(6250000000000ULL);
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, SetAllocOverflow)
{
// try to overflow argument passed to malloc
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, NullFree)
{
// free(NULL) should not try to free buckets
uint32Set_free(NULL);
int64Set_free(NULL);
}
TEST(IntsetTest, BucketRealloc)
{
// add a couple of values to the same bucket to test growing the bucket
uint32Set* set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
for (uint32_t i = 1; i < 32; ++i) {
EXPECT_TRUE(uint32Set_add(set, i));
EXPECT_TRUE(uint32Set_contains(set, i - 1));
EXPECT_TRUE(uint32Set_contains(set, i));
EXPECT_FALSE(uint32Set_contains(set, i + 1));
}
uint32Set_free(set);
}
TEST(IntSet, Max)
{
constexpr auto n = std::numeric_limits<uint32_t>::max();
uint32Set *set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, n));
EXPECT_TRUE(uint32Set_add(set, n));
EXPECT_TRUE(uint32Set_contains(set, n));
uint32Set_free(set);
}
TEST(InsetTest, Negative)
{
constexpr auto n = std::numeric_limits<int64_t>::min();
static_assert(n < 0, "n not negative");
int64Set *set = int64Set_new(3);
ASSERT_TRUE(set);
EXPECT_FALSE(int64Set_contains(set, n));
EXPECT_TRUE(int64Set_add(set, n));
EXPECT_TRUE(int64Set_contains(set, n));
int64Set_free(set);
}

View File

@@ -2,6 +2,7 @@ from argparse import Namespace
from typing import List, Optional, Tuple, Type, Union from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package
from worlds.AutoWorld import World, call_all from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill") gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -60,6 +61,10 @@ class TestWorld(World):
hidden = True hidden = True
# add our test world to the data package, so we can test it later
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
def generate_test_multiworld(players: int = 1) -> MultiWorld: def generate_test_multiworld(players: int = 1) -> MultiWorld:
""" """
Generates a multiworld using a special Test Case World class, and seed of 0. Generates a multiworld using a special Test Case World class, and seed of 0.

View File

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

View File

@@ -1,27 +1,12 @@
import unittest import unittest
from Fill import distribute_items_restrictive from Fill import distribute_items_restrictive
from worlds import network_data_package
from worlds.AutoWorld import AutoWorldRegister, call_all from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld from . import setup_solo_multiworld
class TestIDs(unittest.TestCase): class TestIDs(unittest.TestCase):
def test_unique_items(self):
"""Tests that every game has a unique ID per item in the datapackage"""
known_item_ids = set()
for gamename, world_type in AutoWorldRegister.world_types.items():
current = len(known_item_ids)
known_item_ids |= set(world_type.item_id_to_name)
self.assertEqual(len(known_item_ids) - len(world_type.item_id_to_name), current)
def test_unique_locations(self):
"""Tests that every game has a unique ID per location in the datapackage"""
known_location_ids = set()
for gamename, world_type in AutoWorldRegister.world_types.items():
current = len(known_location_ids)
known_location_ids |= set(world_type.location_id_to_name)
self.assertEqual(len(known_location_ids) - len(world_type.location_id_to_name), current)
def test_range_items(self): def test_range_items(self):
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision.""" """There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
@@ -100,3 +85,4 @@ class TestIDs(unittest.TestCase):
f"{loc_name} is not a valid item name for location_name_to_id") f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int, self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int") f"{loc_id} for {loc_name} should be an int")
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])

0
test/hosting/__init__.py Normal file
View File

191
test/hosting/__main__.py Normal file
View File

@@ -0,0 +1,191 @@
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
# Run with `python test/hosting` instead,
import logging
import traceback
from tempfile import TemporaryDirectory
from time import sleep
from typing import Any
from test.hosting.client import Client
from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata)
from test.hosting.world import copy as copy_world, delete as delete_world
failure = False
fail_fast = True
def assert_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed{msg}")
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
if fail_fast:
expect_true = assert_true
expect_equal = assert_equal
else:
def expect_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed{msg}\n{tb}")
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
if __name__ == "__main__":
import warnings
warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning)
spacer = '=' * 80
with TemporaryDirectory() as tempdir:
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
copy_world("Clique", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}")
multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata)
finally:
delete_world("Temp World")
webapp = get_app(tempdir)
webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1):
seed = upload_multidata(webhost_client, multidata)
room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n")
rooms.append(room)
print("Starting autohost")
from WebHostLib.autolauncher import autohost
try:
autohost(webapp.config)
host: ServeGame
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host:
prev_host_adr = host.address
with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
if collected_items == 1:
sleep(1) # wait for the server to collect the item
stop_autohost(True) # simulate Ctrl+C
sleep(3)
autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below
# verify server shut down
try:
with Client(prev_host_adr, game, "Player1") as client:
assert_true(False, "Server did not shut down")
except ConnectionError:
pass
for game_name in sorted(involved_games):
expect_true(game_name in web_data_packages,
f"{game_name} missing from customserver data package")
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in customserver data for {game_name}")
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in customserver data for {game_name}")
for game_name in web_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from customserver")
assert_equal(web_collected_items, collected_items,
"customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
# compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer")
sleep(5.5) # make sure all tasks actually stopped
# raise an exception in customserver and verify the save doesn't get destroyed
# local variables room is the last room's id here
old_data = get_multidata_for_room(webhost_client, room)
print(f"Destroying multidata for {room}")
set_multidata_for_room(webhost_client, room, bytes([0]))
try:
start_room(webhost_client, room, timeout=7)
except TimeoutError:
pass
else:
assert_true(False, "Room started with destroyed multidata")
print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host:
with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver")
print("Save file is not busted 🥳")
finally:
print("Stopping autohost")
stop_autohost(False)
if failure:
print("Some tests failed")
exit(1)
exit(0)

110
test/hosting/client.py Normal file
View File

@@ -0,0 +1,110 @@
import json
import sys
from typing import Any, Collection, Dict, Iterable, Optional
from websockets import ConnectionClosed
from websockets.sync.client import connect, ClientConnection
from threading import Thread
__all__ = [
"Client"
]
class Client:
"""Incomplete, minimalistic sync test client for AP network protocol"""
recv_timeout = 1.0
host: str
game: str
slot: str
password: Optional[str]
_ws: Optional[ClientConnection]
games: Iterable[str]
data_package_checksums: Dict[str, Any]
games_packages: Dict[str, Any]
missing_locations: Collection[int]
checked_locations: Collection[int]
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
self.host = host
self.game = game
self.slot = slot
self.password = password
self._ws = None
self.games = []
self.data_package_checksums = {}
self.games_packages = {}
self.missing_locations = []
self.checked_locations = []
def __enter__(self) -> "Client":
try:
self.connect()
except BaseException:
self.__exit__(*sys.exc_info())
raise
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
self.close()
def _poll(self) -> None:
assert self._ws
try:
while True:
self._ws.recv()
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
pass
def connect(self) -> None:
self._ws = connect(f"ws://{self.host}")
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games = sorted(room_info["games"])
self.data_package_checksums = room_info["datapackage_checksums"]
self._ws.send(json.dumps([{
"cmd": "GetDataPackage",
"games": list(self.games),
}]))
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games_packages = data_package_msg["data"]["games"]
self._ws.send(json.dumps([{
"cmd": "Connect",
"game": self.game,
"name": self.slot,
"password": self.password,
"uuid": "",
"version": {
"class": "Version",
"major": 0,
"minor": 4,
"build": 6,
},
"items_handling": 0,
"tags": [],
"slot_data": False,
}]))
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
if connect_result_msg["cmd"] != "Connected":
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
self.missing_locations = connect_result_msg["missing_locations"]
self.checked_locations = connect_result_msg["checked_locations"]
def close(self) -> None:
if self._ws:
Thread(target=self._poll).start()
self._ws.close()
def collect(self, locations: Iterable[int]) -> None:
if not self._ws:
raise ValueError("Not connected")
self._ws.send(json.dumps([{
"cmd": "LocationChecks",
"locations": locations,
}]))
def collect_any(self) -> None:
self.collect([next(iter(self.missing_locations))])

76
test/hosting/generate.py Normal file
View File

@@ -0,0 +1,76 @@
import json
import sys
import warnings
from pathlib import Path
from typing import Iterable, Union, TYPE_CHECKING
if TYPE_CHECKING:
from multiprocessing.managers import ListProxy # noqa
__all__ = [
"generate_local",
]
def _generate_local_inner(games: Iterable[str],
dest: Union[Path, str],
results: "ListProxy[Union[Path, BaseException]]") -> None:
original_argv = sys.argv
warnings.simplefilter("ignore")
try:
from tempfile import TemporaryDirectory
if not isinstance(dest, Path):
dest = Path(dest)
with TemporaryDirectory() as players_dir:
with TemporaryDirectory() as output_dir:
import Generate
import Main
for n, game in enumerate(games, 1):
player_path = Path(players_dir) / f"{n}.yaml"
with open(player_path, "w", encoding="utf-8") as f:
f.write(json.dumps({
"name": f"Player{n}",
"game": game,
game: {"hard_mode": "true"},
"description": f"generate_local slot {n} ('Player{n}'): {game}",
}))
# this is basically copied from test/programs/test_generate.py
# uses a reproducible seed that is different for each set of games
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
"--player_files_path", players_dir,
"--outputpath", output_dir]
Main.main(*Generate.main())
output_files = list(Path(output_dir).glob('*.zip'))
assert len(output_files) == 1
final_file = dest / output_files[0].name
output_files[0].rename(final_file)
results.append(final_file)
except BaseException as e:
results.append(e)
raise e
finally:
sys.argv = original_argv
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
results: "ListProxy[Union[Path, Exception]]" = manager.list()
p = Process(target=_generate_local_inner, args=(games, dest, results))
p.start()
p.join()
result = results[0]
if isinstance(result, BaseException):
raise Exception("Could not generate multiworld") from result
return result

115
test/hosting/serve.py Normal file
View File

@@ -0,0 +1,115 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from threading import Event
from werkzeug.test import Client as FlaskClient
__all__ = [
"ServeGame",
"LocalServeGame",
"WebHostServeGame",
]
class ServeGame:
address: str
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
import os
import warnings
original_argv = sys.argv
original_stdin = sys.stdin
warnings.simplefilter("ignore")
try:
import asyncio
from MultiServer import main, parse_args
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
r, w = os.pipe()
sys.stdin = os.fdopen(r, "r")
async def set_ready() -> None:
await asyncio.sleep(.01) # switch back to other task once more
ready.set() # server should be up, set ready state
async def wait_stop() -> None:
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
os.fdopen(w, "w").write("/exit")
async def run() -> None:
# this will run main() until first await, then switch to set_ready()
await asyncio.gather(
main(parse_args()),
set_ready(),
wait_stop(),
)
asyncio.run(run())
finally:
sys.argv = original_argv
sys.stdin = original_stdin
class LocalServeGame(ServeGame):
from multiprocessing import Process
_multidata: Path
_proc: Process
_stop: "Event"
def __init__(self, multidata: Path) -> None:
self.address = ""
self._multidata = multidata
def __enter__(self) -> "LocalServeGame":
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
ready: "Event" = manager.Event()
self._stop = manager.Event()
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
try:
self._proc.start()
ready.wait(30)
self.address = "localhost:38281"
return self
except BaseException:
self.__exit__(*sys.exc_info())
raise
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
try:
self._stop.set()
self._proc.join(30)
except TimeoutError:
self._proc.terminate()
self._proc.join()
class WebHostServeGame(ServeGame):
_client: "FlaskClient"
_room: str
def __init__(self, app_client: "FlaskClient", room: str) -> None:
self.address = ""
self._client = app_client
self._room = room
def __enter__(self) -> "WebHostServeGame":
from .webhost import start_room
self.address = start_room(self._client, self._room)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
from .webhost import stop_room
stop_room(self._client, self._room, timeout=30)

208
test/hosting/webhost.py Normal file
View File

@@ -0,0 +1,208 @@
import re
from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast
if TYPE_CHECKING:
from flask import Flask
from werkzeug.test import Client as FlaskClient
__all__ = [
"get_app",
"upload_multidata",
"create_room",
"start_room",
"stop_room",
"set_room_timeout",
"get_multidata_for_room",
"set_multidata_for_room",
"stop_autohost",
]
def get_app(tempdir: str) -> "Flask":
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": str(Path(tempdir) / "host.db"),
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
"HOST_ADDRESS": "localhost",
"HOSTERS": 1,
})
return get_app()
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={
"file": multidata.open("rb"),
})
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
return location[6:]
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
response = app_client.get(f"/new_room/{seed}")
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
room_id = location[6:]
if not auto_start:
# by default, creating a room will auto-start it, so we update last activity here
stop_room(app_client, room_id, simulate_idle=False)
return room_id
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
from time import sleep
import pony.orm
poll_interval = .2
print(f"Starting room {room_id}")
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
try:
response = app_client.get(f"/room/{room_id}")
except pony.orm.core.OptimisticCheckError:
# hoster wrote to room during our transaction
continue
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
match = re.search(r"/connect ([\w:.\-]+)", response.text)
if match:
return match[1]
timeout -= poll_interval
sleep(poll_interval)
raise TimeoutError("Room did not start")
def stop_room(app_client: "FlaskClient",
room_id: str,
timeout: Optional[float] = None,
simulate_idle: bool = True) -> None:
from datetime import datetime, timedelta
from time import sleep
from pony.orm import db_session
from WebHostLib.models import Command, Room
from WebHostLib import app
poll_interval = 2
print(f"Stopping room {room_id}")
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
if timeout is not None:
sleep(.1) # should not be required, but other things might use threading
with db_session:
room: Room = Room.get(id=room_uuid)
if simulate_idle:
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
else:
new_last_activity = datetime.utcnow() - timedelta(days=3)
room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address:
original_timeout = room.timeout
room.timeout = 1 # avoid spinning it up again
Command(room=room, commandtext="/exit")
try:
if address and timeout is not None:
print("waiting for shutdown")
import socket
host_str, port_str = tuple(address.split(":"))
address_tuple = host_str, int(port_str)
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect(address_tuple)
s.close()
except ConnectionRefusedError:
return
sleep(poll_interval)
timeout -= poll_interval
raise TimeoutError("Room did not stop")
finally:
with db_session:
room = Room.get(id=room_uuid)
room.last_port = 0 # easier to detect when the host is up this way
if address:
room.timeout = original_timeout
room.last_activity = new_last_activity
print("timeout restored")
def set_room_timeout(room_id: str, timeout: float) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.timeout = timeout
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
return cast(bytes, room.seed.multidata)
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None:
import os
import signal
import multiprocessing
from WebHostLib.autolauncher import stop
stop()
proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else:
proc.kill()
try:
proc.join(30)
except TimeoutError:
proc.kill()
proc.join()

42
test/hosting/world.py Normal file
View File

@@ -0,0 +1,42 @@
import re
import shutil
from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"]
_new_worlds: Dict[str, str] = {}
def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name
from worlds import AutoWorldRegister
assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape
raise ValueError(f"Unsupported symbols in {dst}")
dst_folder_name = get_file_safe_name(dst.lower())
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
if dst_folder.is_dir():
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:
assert name in _new_worlds, "World not created by this script"
shutil.rmtree(_new_worlds[name])
del _new_worlds[name]

View File

@@ -1,4 +1,5 @@
# Tests for _speedups.LocationStore and NetUtils._LocationStore # Tests for _speedups.LocationStore and NetUtils._LocationStore
import os
import typing import typing
import unittest import unittest
import warnings import warnings
@@ -7,6 +8,8 @@ from NetUtils import LocationStore, _LocationStore
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]] State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
ci = bool(os.environ.get("CI")) # always set in GitHub actions
sample_data: RawLocations = { sample_data: RawLocations = {
1: { 1: {
11: (21, 2, 7), 11: (21, 2, 7),
@@ -24,6 +27,9 @@ sample_data: RawLocations = {
3: { 3: {
9: (99, 4, 0), 9: (99, 4, 0),
}, },
5: {
9: (99, 5, 0),
}
} }
empty_state: State = { empty_state: State = {
@@ -45,14 +51,14 @@ class Base:
store: typing.Union[LocationStore, _LocationStore] store: typing.Union[LocationStore, _LocationStore]
def test_len(self) -> None: def test_len(self) -> None:
self.assertEqual(len(self.store), 4) self.assertEqual(len(self.store), 5)
self.assertEqual(len(self.store[1]), 3) self.assertEqual(len(self.store[1]), 3)
def test_key_error(self) -> None: def test_key_error(self) -> None:
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
_ = self.store[0] _ = self.store[0]
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
_ = self.store[5] _ = self.store[6]
locations = self.store[1] # no Exception locations = self.store[1] # no Exception
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
_ = locations[7] _ = locations[7]
@@ -71,7 +77,7 @@ class Base:
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None)) self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
def test_iter(self) -> None: def test_iter(self) -> None:
self.assertEqual(sorted(self.store), [1, 2, 3, 4]) self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
self.assertEqual(len(self.store), len(sample_data)) self.assertEqual(len(self.store), len(sample_data))
self.assertEqual(list(self.store[1]), [11, 12, 13]) self.assertEqual(list(self.store[1]), [11, 12, 13])
self.assertEqual(len(self.store[1]), len(sample_data[1])) self.assertEqual(len(self.store[1]), len(sample_data[1]))
@@ -85,13 +91,26 @@ class Base:
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11]) self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
def test_find_item(self) -> None: def test_find_item(self) -> None:
# empty player set
self.assertEqual(sorted(self.store.find_item(set(), 99)), []) self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
# no such player, single
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
# no such player, set
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
# no such item
self.assertEqual(sorted(self.store.find_item({3}, 1)), []) self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
self.assertEqual(sorted(self.store.find_item({5}, 99)), []) # valid matches
self.assertEqual(sorted(self.store.find_item({3}, 99)), self.assertEqual(sorted(self.store.find_item({3}, 99)),
[(4, 9, 99, 3, 0)]) [(4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)), self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)]) [(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
# test hash collision in set
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
[(1, 13, 13, 1, 0)])
def test_get_for_player(self) -> None: def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}}) self.assertEqual(self.store.get_for_player(3), {4: {9}})
@@ -196,18 +215,20 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
super().setUp() super().setUp()
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available") @unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
class TestSpeedupsLocationStore(Base.TestLocationStore): class TestSpeedupsLocationStore(Base.TestLocationStore):
"""Run base method tests for cython implementation.""" """Run base method tests for cython implementation."""
def setUp(self) -> None: def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.store = LocationStore(sample_data) self.store = LocationStore(sample_data)
super().setUp() super().setUp()
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available") @unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor): class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
"""Run base constructor tests and tests the additional constraints for cython implementation.""" """Run base constructor tests and tests the additional constraints for cython implementation."""
def setUp(self) -> None: def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.type = LocationStore self.type = LocationStore
super().setUp() super().setUp()

0
test/options/__init__.py Normal file
View File

View File

@@ -0,0 +1,67 @@
import unittest
from Options import Choice, DefaultOnToggle, Toggle
class TestNumericOptions(unittest.TestCase):
def test_numeric_option(self) -> None:
"""Tests the initialization and equivalency comparisons of the base Numeric Option class."""
class TestChoice(Choice):
option_zero = 0
option_one = 1
option_two = 2
alias_three = 1
non_option_attr = 2
class TestToggle(Toggle):
pass
class TestDefaultOnToggle(DefaultOnToggle):
pass
with self.subTest("choice"):
choice_option_default = TestChoice.from_any(TestChoice.default)
choice_option_string = TestChoice.from_any("one")
choice_option_int = TestChoice.from_any(2)
choice_option_alias = TestChoice.from_any("three")
choice_option_attr = TestChoice.from_any(TestChoice.option_two)
self.assertEqual(choice_option_default, TestChoice.option_zero,
"assigning default didn't match default value")
self.assertEqual(choice_option_string, "one")
self.assertEqual(choice_option_int, 2)
self.assertEqual(choice_option_alias, TestChoice.alias_three)
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
self.assertRaises(KeyError, TestChoice.from_any, "four")
self.assertIn(choice_option_int, [1, 2, 3])
self.assertIn(choice_option_int, {2})
self.assertIn(choice_option_int, (2,))
self.assertIn(choice_option_string, ["one", "two", "three"])
# this fails since the hash is derived from the value
self.assertNotIn(choice_option_string, {"one"})
self.assertIn(choice_option_string, ("one",))
with self.subTest("toggle"):
toggle_default = TestToggle.from_any(TestToggle.default)
toggle_string = TestToggle.from_any("false")
toggle_int = TestToggle.from_any(0)
toggle_alias = TestToggle.from_any("off")
self.assertFalse(toggle_default)
self.assertFalse(toggle_string)
self.assertFalse(toggle_int)
self.assertFalse(toggle_alias)
with self.subTest("on toggle"):
toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default)
toggle_string = TestDefaultOnToggle.from_any("true")
toggle_int = TestDefaultOnToggle.from_any(1)
toggle_alias = TestDefaultOnToggle.from_any("on")
self.assertTrue(toggle_default)
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)

View File

@@ -0,0 +1,106 @@
import unittest
import NetUtils
from CommonClient import CommonContext
class TestCommonContext(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.ctx = CommonContext()
self.ctx.slot = 1 # Pretend we're player 1 for this.
self.ctx.slot_info.update({
1: NetUtils.NetworkSlot("Player 1", "__TestGame1", NetUtils.SlotType.player),
2: NetUtils.NetworkSlot("Player 2", "__TestGame1", NetUtils.SlotType.player),
3: NetUtils.NetworkSlot("Player 3", "__TestGame2", NetUtils.SlotType.player),
})
self.ctx.consume_players_package([
NetUtils.NetworkPlayer(1, 1, "Player 1", "Player 1"),
NetUtils.NetworkPlayer(1, 2, "Player 2", "Player 2"),
NetUtils.NetworkPlayer(1, 3, "Player 3", "Player 3"),
])
# Using IDs outside the "safe range" for testing purposes only. If this fails unit tests, it's because
# another world is not following the spec for allowed ID ranges.
self.ctx.update_data_package({
"games": {
"__TestGame1": {
"location_name_to_id": {
"Test Location 1 - Safe": 2**54 + 1,
"Test Location 2 - Duplicate": 2**54 + 2,
},
"item_name_to_id": {
"Test Item 1 - Safe": 2**54 + 1,
"Test Item 2 - Duplicate": 2**54 + 2,
},
},
"__TestGame2": {
"location_name_to_id": {
"Test Location 3 - Duplicate": 2**54 + 2,
},
"item_name_to_id": {
"Test Item 3 - Duplicate": 2**54 + 2,
},
},
},
})
async def test_archipelago_datapackage_lookups_exist(self):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self):
# Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"
assert self.ctx.item_names["__TestGame1"][2**54+2] == "Test Item 2 - Duplicate"
assert self.ctx.item_names["__TestGame1"][2**54+3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names["__TestGame1"][-1] == "Nothing"
assert self.ctx.item_names["__TestGame2"][2**54+1] == f"Unknown item (ID: {2**54+1})"
assert self.ctx.item_names["__TestGame2"][2**54+2] == "Test Item 3 - Duplicate"
assert self.ctx.item_names["__TestGame2"][2**54+3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names["__TestGame2"][-1] == "Nothing"
# Locations
assert self.ctx.location_names["__TestGame1"][2**54+1] == "Test Location 1 - Safe"
assert self.ctx.location_names["__TestGame1"][2**54+2] == "Test Location 2 - Duplicate"
assert self.ctx.location_names["__TestGame1"][2**54+3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names["__TestGame1"][-1] == "Cheat Console"
assert self.ctx.location_names["__TestGame2"][2**54+1] == f"Unknown location (ID: {2**54+1})"
assert self.ctx.location_names["__TestGame2"][2**54+2] == "Test Location 3 - Duplicate"
assert self.ctx.location_names["__TestGame2"][2**54+3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names["__TestGame2"][-1] == "Cheat Console"
async def test_lookup_helper_functions(self):
# Checking own slot.
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1) == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2) == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 3) == f"Unknown item (ID: {2 ** 54 + 3})"
assert self.ctx.item_names.lookup_in_slot(-1) == f"Nothing"
# Checking others' slots.
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 2) == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 2) == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 1, 3) == f"Unknown item (ID: {2 ** 54 + 1})"
assert self.ctx.item_names.lookup_in_slot(2 ** 54 + 2, 3) == "Test Item 3 - Duplicate"
# Checking by game.
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame1") == "Test Item 1 - Safe"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame1") == "Test Item 2 - Duplicate"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 3, "__TestGame1") == f"Unknown item (ID: {2 ** 54 + 3})"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 1, "__TestGame2") == f"Unknown item (ID: {2 ** 54 + 1})"
assert self.ctx.item_names.lookup_in_game(2 ** 54 + 2, "__TestGame2") == "Test Item 3 - Duplicate"
# Checking with Archipelago ids are valid in any game package.
assert self.ctx.item_names.lookup_in_slot(-1, 2) == "Nothing"
assert self.ctx.item_names.lookup_in_slot(-1, 3) == "Nothing"
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame1") == "Nothing"
assert self.ctx.item_names.lookup_in_game(-1, "__TestGame2") == "Nothing"

View File

@@ -9,6 +9,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import Generate import Generate
import Main
class TestGenerateMain(unittest.TestCase): class TestGenerateMain(unittest.TestCase):
@@ -58,7 +59,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.abs_input_dir), '--player_files_path', str(self.abs_input_dir),
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Generate.main() Main.main(*Generate.main())
self.assertOutput(self.output_tempdir.name) self.assertOutput(self.output_tempdir.name)
@@ -67,7 +68,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.rel_input_dir), '--player_files_path', str(self.rel_input_dir),
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Generate.main() Main.main(*Generate.main())
self.assertOutput(self.output_tempdir.name) self.assertOutput(self.output_tempdir.name)
@@ -86,7 +87,7 @@ class TestGenerateMain(unittest.TestCase):
sys.argv = [sys.argv[0], '--seed', '0', sys.argv = [sys.argv[0], '--seed', '0',
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
Generate.main() Main.main(*Generate.main())
finally: finally:
user_path.cached_path = user_path_backup user_path.cached_path = user_path_backup

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
from Options import Choice, NamedRange, Toggle, Range from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
class TestOptionPresets(unittest.TestCase): class TestOptionPresets(unittest.TestCase):
@@ -14,7 +14,7 @@ class TestOptionPresets(unittest.TestCase):
with self.subTest(game=game_name, preset=preset_name, option=option_name): with self.subTest(game=game_name, preset=preset_name, option=option_name):
try: try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value) option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
supported_types = [Choice, Toggle, Range, NamedRange] supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
if not any([issubclass(option.__class__, t) for t in supported_types]): if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' " self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
f"is not a supported type for webhost. " f"is not a supported type for webhost. "

View File

@@ -0,0 +1,15 @@
from typing import Callable, ClassVar
from kivy.event import EventDispatcher
class WindowBase(EventDispatcher):
width: ClassVar[int] # readonly AliasProperty
height: ClassVar[int] # readonly AliasProperty
@staticmethod
def bind(**kwargs: Callable[..., None]) -> None: ...
class Window(WindowBase):
...

2
typings/kivy/event.pyi Normal file
View File

@@ -0,0 +1,2 @@
class EventDispatcher:
...

View File

@@ -0,0 +1,6 @@
from typing import Literal
from .layout import Layout
class BoxLayout(Layout):
orientation: Literal['horizontal', 'vertical']

View File

@@ -1,8 +1,14 @@
from typing import Any from typing import Any, Sequence
from .widget import Widget from .widget import Widget
class Layout(Widget): class Layout(Widget):
@property
def children(self) -> Sequence[Widget]: ...
def add_widget(self, widget: Widget) -> None: ... def add_widget(self, widget: Widget) -> None: ...
def remove_widget(self, widget: Widget) -> None: ...
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...

View File

@@ -0,0 +1,17 @@
from typing import Any, Callable
class And:
def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ...
class Or:
def __init__(self, *args: object) -> None: ...
class Schema:
def __init__(self, __x: object) -> None: ...
class Optional(Schema):
...

View File

@@ -123,8 +123,8 @@ class WebWorldRegister(type):
assert group.options, "A custom defined Option Group must contain at least one Option." assert group.options, "A custom defined Option Group must contain at least one Option."
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups # catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
title_name = group.name.title() title_name = group.name.title()
if title_name in prebuilt_options: assert title_name not in prebuilt_options or title_name == group.name, \
group.name = title_name f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
if group.name == "Item & Location Options": if group.name == "Item & Location Options":
assert not any(option in item_and_loc_options for option in group.options), \ assert not any(option in item_and_loc_options for option in group.options), \
@@ -223,6 +223,21 @@ class WebWorld(metaclass=WebWorldRegister):
option_groups: ClassVar[List[OptionGroup]] = [] option_groups: ClassVar[List[OptionGroup]] = []
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options".""" """Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
rich_text_options_doc = False
"""Whether the WebHost should render Options' docstrings as rich text.
If this is True, Options' docstrings are interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, they're rendered to HTML
so that lists, emphasis, and other rich text features are displayed
properly.
If this is False, the docstrings are instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved. For backwards
compatibility, this is the default.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
location_descriptions: Dict[str, str] = {} location_descriptions: Dict[str, str] = {}
"""An optional map from location names (or location group names) to brief descriptions for users.""" """An optional map from location names (or location group names) to brief descriptions for users."""
@@ -258,18 +273,6 @@ class World(metaclass=AutoWorldRegister):
location_name_groups: ClassVar[Dict[str, Set[str]]] = {} location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}""" """maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
data_version: ClassVar[int] = 0
"""
Increment this every time something in your world's names/id mappings changes.
When this is set to 0, that world's DataPackage is considered in "testing mode", which signals to servers/clients
that it should not be cached, and clients should request that world's DataPackage every connection. Not
recommended for production-ready worlds.
Deprecated. Clients should utilize `checksum` to determine if DataPackage has changed since last connection and
request a new DataPackage, if necessary.
"""
required_client_version: Tuple[int, int, int] = (0, 1, 6) required_client_version: Tuple[int, int, int] = (0, 1, 6)
""" """
override this if changes to a world break forward-compatibility of the client override this if changes to a world break forward-compatibility of the client
@@ -543,7 +546,6 @@ class World(metaclass=AutoWorldRegister):
"item_name_to_id": cls.item_name_to_id, "item_name_to_id": cls.item_name_to_id,
"location_name_groups": sorted_location_name_groups, "location_name_groups": sorted_location_name_groups,
"location_name_to_id": cls.location_name_to_id, "location_name_to_id": cls.location_name_to_id,
"version": cls.data_version,
} }
res["checksum"] = data_package_checksum(res) res["checksum"] = data_package_checksum(res)
return res return res

View File

@@ -1,8 +1,11 @@
import bisect
import logging
import pathlib
import weakref import weakref
from enum import Enum, auto from enum import Enum, auto
from typing import Optional, Callable, List, Iterable from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path from Utils import local_path, open_filename
class Type(Enum): class Type(Enum):
@@ -49,8 +52,10 @@ class Component:
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}({self.display_name})" return f"{self.__class__.__name__}({self.display_name})"
processes = weakref.WeakSet() processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str = None): def launch_subprocess(func: Callable, name: str = None):
global processes global processes
import multiprocessing import multiprocessing
@@ -58,6 +63,7 @@ def launch_subprocess(func: Callable, name: str = None):
process.start() process.start()
processes.add(process) processes.add(process)
class SuffixIdentifier: class SuffixIdentifier:
suffixes: Iterable[str] suffixes: Iterable[str]
@@ -77,6 +83,80 @@ def launch_textclient():
launch_subprocess(CommonClient.run_as_textclient, name="TextClient") launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
if not apworld_src:
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
if not apworld_src:
# user closed menu
return
if not apworld_src.endswith(".apworld"):
raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}")
apworld_path = pathlib.Path(apworld_src)
module_name = pathlib.Path(apworld_path.name).stem
try:
import zipfile
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
except ValueError as e:
raise Exception("Archive appears invalid or damaged.") from e
except KeyError as e:
raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e
import worlds
if worlds.user_folder is None:
raise Exception("Custom Worlds directory appears to not be writable.")
for world_source in worlds.world_sources:
if apworld_path.samefile(world_source.resolved_path):
# Note that this doesn't check if the same world is already installed.
# It only checks if the user is trying to install the apworld file
# that comes from the installation location (worlds or custom_worlds)
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
# TODO: run generic test suite over the apworld.
# TODO: have some kind of version system to tell from metadata if the apworld should be compatible.
target = pathlib.Path(worlds.user_folder) / apworld_path.name
import shutil
shutil.copyfile(apworld_path, target)
# If a module with this name is already loaded, then we can't load it now.
# TODO: We need to be able to unload a world module,
# so the user can update a world without restarting the application.
found_already_loaded = False
for loaded_world in worlds.world_sources:
loaded_name = pathlib.Path(loaded_world.path).stem
if module_name == loaded_name:
found_already_loaded = True
break
if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
"so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source)
world_source.load()
return apworld_path, target
def install_apworld(apworld_path: str = "") -> None:
try:
res = _install_apworld(apworld_path)
if res is None:
logging.info("Aborting APWorld installation.")
return
source, target = res
except Exception as e:
import Utils
Utils.messagebox(e.__class__.__name__, str(e), error=True)
logging.exception(e)
else:
import Utils
logging.info(f"Installed APWorld successfully, copied {source} to {target}.")
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
components: List[Component] = [ components: List[Component] = [
# Launcher # Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN), Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
@@ -84,6 +164,7 @@ components: List[Component] = [
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')), file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True), Component('Generate', 'Generate', cli=True),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
Component('Links Awakening DX Client', 'LinksAwakeningClient', Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')), file_identifier=SuffixIdentifier('.apladx')),

View File

@@ -1,16 +1,22 @@
import importlib import importlib
import importlib.util
import logging
import os import os
import sys import sys
import warnings import warnings
import zipimport import zipimport
import time import time
import dataclasses import dataclasses
from typing import Dict, List, TypedDict, Optional from typing import Dict, List, TypedDict
from Utils import local_path, user_path from Utils import local_path, user_path
local_folder = os.path.dirname(__file__) local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else None user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
try:
os.makedirs(user_folder, exist_ok=True)
except OSError: # can't access/write?
user_folder = None
__all__ = { __all__ = {
"network_data_package", "network_data_package",
@@ -33,7 +39,6 @@ class GamesPackage(TypedDict, total=False):
location_name_groups: Dict[str, List[str]] location_name_groups: Dict[str, List[str]]
location_name_to_id: Dict[str, int] location_name_to_id: Dict[str, int]
checksum: str checksum: str
version: int # TODO: Remove support after per game data packages API change.
class DataPackage(TypedDict): class DataPackage(TypedDict):
@@ -45,7 +50,7 @@ class WorldSource:
path: str # typically relative path from this module path: str # typically relative path from this module
is_zip: bool = False is_zip: bool = False
relative: bool = True # relative to regular world import folder relative: bool = True # relative to regular world import folder
time_taken: Optional[float] = None time_taken: float = -1.0
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -89,7 +94,6 @@ class WorldSource:
print(f"Could not load world {self}:", file=file_like) print(f"Could not load world {self}:", file=file_like)
traceback.print_exc(file=file_like) traceback.print_exc(file=file_like)
file_like.seek(0) file_like.seek(0)
import logging
logging.exception(file_like.read()) logging.exception(file_like.read())
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0]) failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
return False return False
@@ -104,7 +108,12 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
if not entry.name.startswith(("_", ".")): if not entry.name.startswith(("_", ".")):
file_name = entry.name if relative else os.path.join(folder, entry.name) file_name = entry.name if relative else os.path.join(folder, entry.name)
if entry.is_dir(): if entry.is_dir():
world_sources.append(WorldSource(file_name, relative=relative)) if os.path.isfile(os.path.join(entry.path, '__init__.py')):
world_sources.append(WorldSource(file_name, relative=relative))
elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')):
world_sources.append(WorldSource(file_name, relative=relative))
else:
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
elif entry.is_file() and entry.name.endswith(".apworld"): elif entry.is_file() and entry.name.endswith(".apworld"):
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
@@ -119,3 +128,4 @@ from .AutoWorld import AutoWorldRegister
network_data_package: DataPackage = { network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()}, "games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
} }

View File

@@ -168,6 +168,7 @@ async def _game_watcher(ctx: BizHawkClientContext):
ctx.auth = None ctx.auth = None
ctx.username = None ctx.username = None
ctx.client_handler = None ctx.client_handler = None
ctx.finished_game = False
await ctx.disconnect(False) await ctx.disconnect(False)
ctx.rom_hash = rom_hash ctx.rom_hash = rom_hash

View File

@@ -28,6 +28,11 @@ class kill_switch:
logger.debug("kill_switch: Add switch") logger.debug("kill_switch: Add switch")
cls._to_kill.append(value) cls._to_kill.append(value)
@classmethod
def kill(cls, value):
logger.info(f"kill_switch: Process cleanup for 1 process")
value._clean(verbose=False)
@classmethod @classmethod
def kill_all(cls): def kill_all(cls):
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes") logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
@@ -116,7 +121,7 @@ class SC2Process:
async def __aexit__(self, *args): async def __aexit__(self, *args):
logger.exception("async exit") logger.exception("async exit")
await self._close_connection() await self._close_connection()
kill_switch.kill_all() kill_switch.kill(self)
signal.signal(signal.SIGINT, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_DFL)
@property @property

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
from typing import Dict from typing import Dict
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle from dataclasses import dataclass
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
class FreeincarnateMax(Range): class FreeincarnateMax(Range):
@@ -223,22 +224,22 @@ class StartCastle(Choice):
option_white = 2 option_white = 2
default = option_yellow default = option_yellow
@dataclass
class AdventureOptions(PerGameCommonOptions):
dragon_slay_check: DragonSlayCheck
death_link: DeathLink
bat_logic: BatLogic
freeincarnate_max: FreeincarnateMax
dragon_rando_type: DragonRandoType
connector_multi_slot: ConnectorMultiSlot
yorgle_speed: YorgleStartingSpeed
yorgle_min_speed: YorgleMinimumSpeed
grundle_speed: GrundleStartingSpeed
grundle_min_speed: GrundleMinimumSpeed
rhindle_speed: RhindleStartingSpeed
rhindle_min_speed: RhindleMinimumSpeed
difficulty_switch_a: DifficultySwitchA
difficulty_switch_b: DifficultySwitchB
start_castle: StartCastle
adventure_option_definitions: Dict[str, type(Option)] = {
"dragon_slay_check": DragonSlayCheck,
"death_link": DeathLink,
"bat_logic": BatLogic,
"freeincarnate_max": FreeincarnateMax,
"dragon_rando_type": DragonRandoType,
"connector_multi_slot": ConnectorMultiSlot,
"yorgle_speed": YorgleStartingSpeed,
"yorgle_min_speed": YorgleMinimumSpeed,
"grundle_speed": GrundleStartingSpeed,
"grundle_min_speed": GrundleMinimumSpeed,
"rhindle_speed": RhindleStartingSpeed,
"rhindle_min_speed": RhindleMinimumSpeed,
"difficulty_switch_a": DifficultySwitchA,
"difficulty_switch_b": DifficultySwitchB,
"start_castle": StartCastle,
}

View File

@@ -1,4 +1,5 @@
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
from Options import PerGameCommonOptions
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
@@ -24,7 +25,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
connect(world, player, target, source, rule, True) connect(world, player, target, source, rule, True)
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None: def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
menu = Region("Menu", player, multiworld) menu = Region("Menu", player, multiworld)
@@ -74,7 +75,7 @@ def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> Non
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side)) credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
multiworld.regions.append(credits_room_far_side) multiworld.regions.append(credits_room_far_side)
dragon_slay_check = multiworld.dragon_slay_check[player].value dragon_slay_check = options.dragon_slay_check.value
priority_locations = determine_priority_locations(multiworld, dragon_slay_check) priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
for name, location_data in location_table.items(): for name, location_data in location_table.items():

View File

@@ -6,7 +6,7 @@ from BaseClasses import LocationProgressType
def set_rules(self) -> None: def set_rules(self) -> None:
world = self.multiworld world = self.multiworld
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
set_rule(world.get_entrance("YellowCastlePort", self.player), set_rule(world.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player)) lambda state: state.has("Yellow Key", self.player))
@@ -28,7 +28,7 @@ def set_rules(self) -> None:
lambda state: state.has("Bridge", self.player) or lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player)) state.has("Magnet", self.player))
dragon_slay_check = world.dragon_slay_check[self.player].value dragon_slay_check = self.options.dragon_slay_check.value
if dragon_slay_check: if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item: if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(world.get_location("Slay Yorgle", self.player), set_rule(world.get_location("Slay Yorgle", self.player),

View File

@@ -15,7 +15,8 @@ from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule from worlds.generic.Rules import add_rule, set_rule
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
AdventureOptions
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \ from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
AdventureAutoCollectLocation AdventureAutoCollectLocation
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
@@ -109,11 +110,10 @@ class AdventureWorld(World):
game: ClassVar[str] = "Adventure" game: ClassVar[str] = "Adventure"
web: ClassVar[WebWorld] = AdventureWeb() web: ClassVar[WebWorld] = AdventureWeb()
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions options_dataclass = AdventureOptions
settings: ClassVar[AdventureSettings] settings: ClassVar[AdventureSettings]
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()} item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()} location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
data_version: ClassVar[int] = 1
required_client_version: Tuple[int, int, int] = (0, 3, 9) required_client_version: Tuple[int, int, int] = (0, 3, 9)
def __init__(self, world: MultiWorld, player: int): def __init__(self, world: MultiWorld, player: int):
@@ -150,18 +150,18 @@ class AdventureWorld(World):
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21] bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
self.rom_name.extend([0] * (21 - len(self.rom_name))) self.rom_name.extend([0] * (21 - len(self.rom_name)))
self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value self.dragon_rando_type = self.options.dragon_rando_type.value
self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value self.dragon_slay_check = self.options.dragon_slay_check.value
self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value self.connector_multi_slot = self.options.connector_multi_slot.value
self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value self.yorgle_speed = self.options.yorgle_speed.value
self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value self.yorgle_min_speed = self.options.yorgle_min_speed.value
self.grundle_speed = self.multiworld.grundle_speed[self.player].value self.grundle_speed = self.options.grundle_speed.value
self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value self.grundle_min_speed = self.options.grundle_min_speed.value
self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value self.rhindle_speed = self.options.rhindle_speed.value
self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value self.rhindle_min_speed = self.options.rhindle_min_speed.value
self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value self.difficulty_switch_a = self.options.difficulty_switch_a.value
self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value self.difficulty_switch_b = self.options.difficulty_switch_b.value
self.start_castle = self.multiworld.start_castle[self.player].value self.start_castle = self.options.start_castle.value
self.created_items = 0 self.created_items = 0
if self.dragon_slay_check == 0: if self.dragon_slay_check == 0:
@@ -228,7 +228,7 @@ class AdventureWorld(World):
extra_filler_count = num_locations - self.created_items extra_filler_count = num_locations - self.created_items
# traps would probably go here, if enabled # traps would probably go here, if enabled
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value freeincarnate_max = self.options.freeincarnate_max.value
actual_freeincarnates = min(extra_filler_count, freeincarnate_max) actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)] self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
self.created_items += actual_freeincarnates self.created_items += actual_freeincarnates
@@ -248,7 +248,7 @@ class AdventureWorld(World):
self.created_items += 1 self.created_items += 1
def create_regions(self) -> None: def create_regions(self) -> None:
create_regions(self.multiworld, self.player, self.dragon_rooms) create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules set_rules = set_rules
@@ -355,7 +355,7 @@ class AdventureWorld(World):
auto_collect_locations: [AdventureAutoCollectLocation] = [] auto_collect_locations: [AdventureAutoCollectLocation] = []
local_item_to_location: {int, int} = {} local_item_to_location: {int, int} = {}
bat_no_touch_locs: [LocationData] = [] bat_no_touch_locs: [LocationData] = []
bat_logic: int = self.multiworld.bat_logic[self.player].value bat_logic: int = self.options.bat_logic.value
try: try:
rom_deltas: { int, int } = {} rom_deltas: { int, int } = {}
self.place_dragons(rom_deltas) self.place_dragons(rom_deltas)
@@ -422,7 +422,7 @@ class AdventureWorld(World):
item_position_data_start = get_item_position_data_start(unplaced_item.table_index) item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
rom_deltas[item_position_data_start] = 0xff rom_deltas[item_position_data_start] = 0xff
if self.multiworld.connector_multi_slot[self.player].value: if self.options.connector_multi_slot.value:
rom_deltas[connector_port_offset] = (self.player & 0xff) rom_deltas[connector_port_offset] = (self.player & 0xff)
else: else:
rom_deltas[connector_port_offset] = 0 rom_deltas[connector_port_offset] = 0

View File

@@ -35,7 +35,7 @@ dw_requirements = {
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]), "The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
"Rift Collapse - Deep Sea": LocData(hookshot=True), "Rift Collapse: Deep Sea": LocData(hookshot=True),
} }
# Includes main objective requirements # Includes main objective requirements
@@ -55,7 +55,7 @@ dw_bonus_requirements = {
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]), "The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
"Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]), "Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]),
} }
dw_stamp_costs = { dw_stamp_costs = {
@@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"):
def add_dw_rules(world: "HatInTimeWorld", loc: Location): def add_dw_rules(world: "HatInTimeWorld", loc: Location):
bonus: bool = "All Clear" in loc.name bonus: bool = "All Clear" in loc.name
if not bonus: if not bonus:
data = dw_requirements.get(loc.name) data = dw_requirements.get(loc.parent_region.name)
else: else:
data = dw_bonus_requirements.get(loc.name) data = dw_bonus_requirements.get(loc.parent_region.name)
if data is None: if data is None:
return return

View File

@@ -339,7 +339,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
def new_check(location_id): def new_check(location_id):
new_locations.append(location_id) new_locations.append(location_id)
ctx.locations_checked.add(location_id) ctx.locations_checked.add(location_id)
location = ctx.location_names[location_id] location = ctx.location_names.lookup_in_game(location_id)
snes_logger.info( snes_logger.info(
f'New Check: {location} ' + f'New Check: {location} ' +
f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' + f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' +
@@ -552,9 +552,9 @@ class ALTTPSNIClient(SNIClient):
item = ctx.items_received[recv_index] item = ctx.items_received[recv_index]
recv_index += 1 recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names[item.location], recv_index, len(ctx.items_received))) ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))

View File

@@ -1,8 +1,11 @@
import typing import typing
from BaseClasses import MultiWorld from BaseClasses import MultiWorld
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool, PlandoBosses,\ from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, \
FreeText, Removed StartInventoryPool, PlandoBosses, PlandoConnections, PlandoTexts, FreeText, Removed
from .EntranceShuffle import default_connections, default_dungeon_connections, \
inverted_default_connections, inverted_default_dungeon_connections
from .Text import TextTable
class GlitchesRequired(Choice): class GlitchesRequired(Choice):
@@ -721,7 +724,27 @@ class AllowCollect(DefaultOnToggle):
display_name = "Allow Collection of checks for other players" display_name = "Allow Collection of checks for other players"
class ALttPPlandoConnections(PlandoConnections):
entrances = set([connection[0] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)])
exits = set([connection[1] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)])
class ALttPPlandoTexts(PlandoTexts):
"""Text plando. Format is:
- text: 'This is your text'
at: text_key
percentage: 100
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
valid_keys = TextTable.valid_keys
alttp_options: typing.Dict[str, type(Option)] = { alttp_options: typing.Dict[str, type(Option)] = {
"plando_connections": ALttPPlandoConnections,
"plando_texts": ALttPPlandoTexts,
"start_inventory_from_pool": StartInventoryPool, "start_inventory_from_pool": StartInventoryPool,
"goal": Goal, "goal": Goal,
"mode": Mode, "mode": Mode,

View File

@@ -220,26 +220,7 @@ def get_invalid_bunny_revival_dungeons():
yield 'Sanctuary' yield 'Sanctuary'
def no_logic_rules(world, player):
"""
Add OWG transitions to no logic player's world
"""
create_no_logic_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
create_no_logic_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))
# Glitched speed drops.
create_no_logic_connections(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'))
# Mirror clip spots.
if world.mode[player] != 'inverted':
create_no_logic_connections(player, world, get_mirror_clip_spots_dw())
create_no_logic_connections(player, world, get_mirror_offset_spots_dw())
else:
create_no_logic_connections(player, world, get_mirror_offset_spots_lw(player))
def overworld_glitch_connections(world, player): def overworld_glitch_connections(world, player):
# Boots-accessible locations. # Boots-accessible locations.
create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted')) create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player)) create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))

View File

@@ -1269,7 +1269,8 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
rom.write_int32(0x18020C, 0) # starting time (in frames, sint32) rom.write_int32(0x18020C, 0) # starting time (in frames, sint32)
# set up goals for treasure hunt # set up goals for treasure hunt
rom.write_int16(0x180163, local_world.treasure_hunt_required) rom.write_int16(0x180163, max(0, local_world.treasure_hunt_required -
sum(1 for item in world.precollected_items[player] if item.name == "Triforce Piece")))
rom.write_bytes(0x180165, [0x0E, 0x28]) # Triforce Piece Sprite rom.write_bytes(0x180165, [0x0E, 0x28]) # Triforce Piece Sprite
rom.write_byte(0x180194, 1) # Must turn in triforced pieces (instant win not enabled) rom.write_byte(0x180194, 1) # Must turn in triforced pieces (instant win not enabled)
@@ -1372,7 +1373,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword', 'Golden Sword', 'Tempered Sword', 'Master Sword', 'Fighter Sword', 'Progressive Sword',
'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield', 'Mirror Shield', 'Red Shield', 'Blue Shield', 'Progressive Shield',
'Red Mail', 'Blue Mail', 'Progressive Mail', 'Red Mail', 'Blue Mail', 'Progressive Mail',
'Magic Upgrade (1/4)', 'Magic Upgrade (1/2)'}: 'Magic Upgrade (1/4)', 'Magic Upgrade (1/2)', 'Triforce Piece'}:
continue continue
set_table = {'Book of Mudora': (0x34E, 1), 'Hammer': (0x34B, 1), 'Bug Catching Net': (0x34D, 1), set_table = {'Book of Mudora': (0x34E, 1), 'Hammer': (0x34B, 1), 'Bug Catching Net': (0x34D, 1),
@@ -2475,6 +2476,9 @@ def write_strings(rom, world, player):
tt['sahasrahla_quest_have_master_sword'] = Sahasrahla2_texts[local_random.randint(0, len(Sahasrahla2_texts) - 1)] tt['sahasrahla_quest_have_master_sword'] = Sahasrahla2_texts[local_random.randint(0, len(Sahasrahla2_texts) - 1)]
tt['blind_by_the_light'] = Blind_texts[local_random.randint(0, len(Blind_texts) - 1)] tt['blind_by_the_light'] = Blind_texts[local_random.randint(0, len(Blind_texts) - 1)]
triforce_pieces_required = max(0, w.treasure_hunt_required -
sum(1 for item in world.precollected_items[player] if item.name == "Triforce Piece"))
if world.goal[player] in ['triforce_hunt', 'local_triforce_hunt']: if world.goal[player] in ['triforce_hunt', 'local_triforce_hunt']:
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Get the Triforce Pieces.' tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Get the Triforce Pieces.'
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.' tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
@@ -2482,16 +2486,16 @@ def write_strings(rom, world, player):
tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!' tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!'
else: else:
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!' tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
if w.treasure_hunt_required > 1: if triforce_pieces_required > 1:
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \ tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \ "invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
"hidden in a hollow tree. If you bring\n%d Triforce pieces out of %d, I can reassemble it." % \ "hidden in a hollow tree. If you bring\n%d Triforce pieces out of %d, I can reassemble it." % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
else: else:
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \ tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \ "invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
"hidden in a hollow tree. If you bring\n%d Triforce piece out of %d, I can reassemble it." % \ "hidden in a hollow tree. If you bring\n%d Triforce piece out of %d, I can reassemble it." % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
elif world.goal[player] in ['pedestal']: elif world.goal[player] in ['pedestal']:
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.' tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.'
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.' tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
@@ -2500,20 +2504,20 @@ def write_strings(rom, world, player):
tt['ganon_fall_in'] = Ganon1_texts[local_random.randint(0, len(Ganon1_texts) - 1)] tt['ganon_fall_in'] = Ganon1_texts[local_random.randint(0, len(Ganon1_texts) - 1)]
tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!' tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!'
tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!' tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!'
if w.treasure_hunt_required > 1: if triforce_pieces_required > 1:
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1: if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d with your friends to defeat Ganon.' % \ tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d with your friends to defeat Ganon.' % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']: elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d to defeat Ganon.' % \ tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d to defeat Ganon.' % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
else: else:
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1: if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d with your friends to defeat Ganon.' % \ tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d with your friends to defeat Ganon.' % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']: elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d to defeat Ganon.' % \ tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d to defeat Ganon.' % \
(w.treasure_hunt_required, w.treasure_hunt_total) (triforce_pieces_required, w.treasure_hunt_total)
tt['kakariko_tavern_fisherman'] = TavernMan_texts[local_random.randint(0, len(TavernMan_texts) - 1)] tt['kakariko_tavern_fisherman'] = TavernMan_texts[local_random.randint(0, len(TavernMan_texts) - 1)]
@@ -2538,12 +2542,12 @@ def write_strings(rom, world, player):
tt['menu_start_2'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n{CHOICE3}" tt['menu_start_2'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n{CHOICE3}"
tt['menu_start_3'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n Mountain Cave\n{CHOICE2}" tt['menu_start_3'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n Mountain Cave\n{CHOICE2}"
for at, text in world.plando_texts[player].items(): for at, text, _ in world.plando_texts[player]:
if at not in tt: if at not in tt:
raise Exception(f"No text target \"{at}\" found.") raise Exception(f"No text target \"{at}\" found.")
else: else:
tt[at] = text tt[at] = "\n".join(text)
rom.write_bytes(0xE0000, tt.getBytes()) rom.write_bytes(0xE0000, tt.getBytes())

View File

@@ -10,7 +10,7 @@ from . import OverworldGlitchRules
from .Bosses import GanonDefeatRule from .Bosses import GanonDefeatRule
from .Items import item_factory, item_name_groups, item_table, progression_items from .Items import item_factory, item_name_groups, item_table, progression_items
from .Options import small_key_shuffle from .Options import small_key_shuffle
from .OverworldGlitchRules import no_logic_rules, overworld_glitches_rules from .OverworldGlitchRules import overworld_glitches_rules
from .Regions import LTTPRegionType, location_table from .Regions import LTTPRegionType, location_table
from .StateHelpers import (can_extend_magic, can_kill_most_things, from .StateHelpers import (can_extend_magic, can_kill_most_things,
can_lift_heavy_rocks, can_lift_rocks, can_lift_heavy_rocks, can_lift_rocks,
@@ -33,7 +33,6 @@ def set_rules(world):
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!') 'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
if world.players == 1: if world.players == 1:
no_logic_rules(world, player)
for exit in world.get_region('Menu', player).exits: for exit in world.get_region('Menu', player).exits:
exit.hide_path = True exit.hide_path = True
return return

View File

@@ -9,9 +9,9 @@ from worlds.generic.Rules import add_rule
from BaseClasses import CollectionState from BaseClasses import CollectionState
from .SubClasses import ALttPLocation from .SubClasses import ALttPLocation
from .EntranceShuffle import door_addresses
from .Items import item_name_groups from .Items import item_name_groups
from .Options import small_key_shuffle, RandomizeShopInventories
from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows
logger = logging.getLogger("Shops") logger = logging.getLogger("Shops")
@@ -66,6 +66,7 @@ class Shop:
return 0 return 0
def get_bytes(self) -> List[int]: def get_bytes(self) -> List[int]:
from .EntranceShuffle import door_addresses
# [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index] # [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index]
entrances = self.region.entrances entrances = self.region.entrances
config = self.item_count config = self.item_count
@@ -181,7 +182,7 @@ def push_shop_inventories(multiworld):
def create_shops(multiworld, player: int): def create_shops(multiworld, player: int):
from .Options import RandomizeShopInventories
player_shop_table = shop_table.copy() player_shop_table = shop_table.copy()
if multiworld.include_witch_hut[player]: if multiworld.include_witch_hut[player]:
player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False) player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False)
@@ -304,6 +305,7 @@ shop_generation_types = {
def set_up_shops(multiworld, player: int): def set_up_shops(multiworld, player: int):
from .Options import small_key_shuffle
# TODO: move hard+ mode changes for shields here, utilizing the new shops # TODO: move hard+ mode changes for shields here, utilizing the new shops
if multiworld.retro_bow[player]: if multiworld.retro_bow[player]:
@@ -426,7 +428,7 @@ def get_price_modifier(item):
def get_price(multiworld, item, player: int, price_type=None): def get_price(multiworld, item, player: int, price_type=None):
"""Converts a raw Rupee price into a special price type""" """Converts a raw Rupee price into a special price type"""
from .Options import small_key_shuffle
if price_type: if price_type:
price_types = [price_type] price_types = [price_type]
else: else:

View File

@@ -1289,6 +1289,415 @@ class LargeCreditBottomMapper(CharTextMapper):
class TextTable(object): class TextTable(object):
SIZE = 0x7355 SIZE = 0x7355
valid_keys = [
"set_cursor",
"set_cursor2",
"game_over_menu",
"var_test",
"follower_no_enter",
"choice_1_3",
"choice_2_3",
"choice_3_3",
"choice_1_2",
"choice_2_2",
"uncle_leaving_text",
"uncle_dying_sewer",
"tutorial_guard_1",
"tutorial_guard_2",
"tutorial_guard_3",
"tutorial_guard_4",
"tutorial_guard_5",
"tutorial_guard_6",
"tutorial_guard_7",
"priest_sanctuary_before_leave",
"sanctuary_enter",
"zelda_sanctuary_story",
"priest_sanctuary_before_pendants",
"priest_sanctuary_after_pendants_before_master_sword",
"priest_sanctuary_dying",
"zelda_save_sewers",
"priest_info",
"zelda_sanctuary_before_leave",
"telepathic_intro",
"telepathic_reminder",
"zelda_go_to_throne",
"zelda_push_throne",
"zelda_switch_room_pull",
"zelda_save_lets_go",
"zelda_save_repeat",
"zelda_before_pendants",
"zelda_after_pendants_before_master_sword",
"telepathic_zelda_right_after_master_sword",
"zelda_sewers",
"zelda_switch_room",
"kakariko_saharalasa_wife",
"kakariko_saharalasa_wife_sword_story",
"kakariko_saharalasa_wife_closing",
"kakariko_saharalasa_after_master_sword",
"kakariko_alert_guards",
"sahasrahla_quest_have_pendants",
"sahasrahla_quest_have_master_sword",
"sahasrahla_quest_information",
"sahasrahla_bring_courage",
"sahasrahla_have_ice_rod",
"telepathic_sahasrahla_beat_agahnim",
"telepathic_sahasrahla_beat_agahnim_no_pearl",
"sahasrahla_have_boots_no_icerod",
"sahasrahla_have_courage",
"sahasrahla_found",
"sign_rain_north_of_links_house",
"sign_north_of_links_house",
"sign_path_to_death_mountain",
"sign_lost_woods",
"sign_zoras",
"sign_outside_magic_shop",
"sign_death_mountain_cave_back",
"sign_east_of_links_house",
"sign_south_of_lumberjacks",
"sign_east_of_desert",
"sign_east_of_sanctuary",
"sign_east_of_castle",
"sign_north_of_lake",
"sign_desert_thief",
"sign_lumberjacks_house",
"sign_north_kakariko",
"witch_bring_mushroom",
"witch_brewing_the_item",
"witch_assistant_no_bottle",
"witch_assistant_no_empty_bottle",
"witch_assistant_informational",
"witch_assistant_no_bottle_buying",
"potion_shop_no_empty_bottles",
"item_get_lamp",
"item_get_boomerang",
"item_get_bow",
"item_get_shovel",
"item_get_magic_cape",
"item_get_powder",
"item_get_flippers",
"item_get_power_gloves",
"item_get_pendant_courage",
"item_get_pendant_power",
"item_get_pendant_wisdom",
"item_get_mushroom",
"item_get_book",
"item_get_moonpearl",
"item_get_compass",
"item_get_map",
"item_get_ice_rod",
"item_get_fire_rod",
"item_get_ether",
"item_get_bombos",
"item_get_quake",
"item_get_hammer",
"item_get_flute",
"item_get_cane_of_somaria",
"item_get_hookshot",
"item_get_bombs",
"item_get_bottle",
"item_get_big_key",
"item_get_titans_mitts",
"item_get_magic_mirror",
"item_get_fake_mastersword",
"post_item_get_mastersword",
"item_get_red_potion",
"item_get_green_potion",
"item_get_blue_potion",
"item_get_bug_net",
"item_get_blue_mail",
"item_get_red_mail",
"item_get_temperedsword",
"item_get_mirror_shield",
"item_get_cane_of_byrna",
"missing_big_key",
"missing_magic",
"item_get_pegasus_boots",
"talking_tree_info_start",
"talking_tree_info_1",
"talking_tree_info_2",
"talking_tree_info_3",
"talking_tree_info_4",
"talking_tree_other",
"item_get_pendant_power_alt",
"item_get_pendant_wisdom_alt",
"game_shooting_choice",
"game_shooting_yes",
"game_shooting_no",
"game_shooting_continue",
"pond_of_wishing",
"pond_item_select",
"pond_item_test",
"pond_will_upgrade",
"pond_item_test_no",
"pond_item_test_no_no",
"pond_item_boomerang",
"pond_item_shield",
"pond_item_silvers",
"pond_item_bottle_filled",
"pond_item_sword",
"pond_of_wishing_happiness",
"pond_of_wishing_choice",
"pond_of_wishing_bombs",
"pond_of_wishing_arrows",
"pond_of_wishing_full_upgrades",
"mountain_old_man_first",
"mountain_old_man_deadend",
"mountain_old_man_turn_right",
"mountain_old_man_lost_and_alone",
"mountain_old_man_drop_off",
"mountain_old_man_in_his_cave_pre_agahnim",
"mountain_old_man_in_his_cave",
"mountain_old_man_in_his_cave_post_agahnim",
"tavern_old_man_awake",
"tavern_old_man_unactivated_flute",
"tavern_old_man_know_tree_unactivated_flute",
"tavern_old_man_have_flute",
"chicken_hut_lady",
"running_man",
"game_race_sign",
"sign_bumper_cave",
"sign_catfish",
"sign_north_village_of_outcasts",
"sign_south_of_bumper_cave",
"sign_east_of_pyramid",
"sign_east_of_bomb_shop",
"sign_east_of_mire",
"sign_village_of_outcasts",
"sign_before_wishing_pond",
"sign_before_catfish_area",
"castle_wall_guard",
"gate_guard",
"telepathic_tile_eastern_palace",
"telepathic_tile_tower_of_hera_floor_4",
"hylian_text_1",
"mastersword_pedestal_translated",
"telepathic_tile_spectacle_rock",
"telepathic_tile_swamp_entrance",
"telepathic_tile_thieves_town_upstairs",
"telepathic_tile_misery_mire",
"hylian_text_2",
"desert_entry_translated",
"telepathic_tile_under_ganon",
"telepathic_tile_palace_of_darkness",
"telepathic_tile_desert_bonk_torch_room",
"telepathic_tile_castle_tower",
"telepathic_tile_ice_large_room",
"telepathic_tile_turtle_rock",
"telepathic_tile_ice_entrance",
"telepathic_tile_ice_stalfos_knights_room",
"telepathic_tile_tower_of_hera_entrance",
"houlihan_room",
"caught_a_bee",
"caught_a_fairy",
"no_empty_bottles",
"game_race_boy_time",
"game_race_girl",
"game_race_boy_success",
"game_race_boy_failure",
"game_race_boy_already_won",
"game_race_boy_sneaky",
"bottle_vendor_choice",
"bottle_vendor_get",
"bottle_vendor_no",
"bottle_vendor_already_collected",
"bottle_vendor_bee",
"bottle_vendor_fish",
"hobo_item_get_bottle",
"blacksmiths_what_you_want",
"blacksmiths_paywall",
"blacksmiths_extra_okay",
"blacksmiths_tempered_already",
"blacksmiths_temper_no",
"blacksmiths_bogart_sword",
"blacksmiths_get_sword",
"blacksmiths_shop_before_saving",
"blacksmiths_shop_saving",
"blacksmiths_collect_frog",
"blacksmiths_still_working",
"blacksmiths_saving_bows",
"blacksmiths_hammer_anvil",
"dark_flute_boy_storytime",
"dark_flute_boy_get_shovel",
"dark_flute_boy_no_get_shovel",
"dark_flute_boy_flute_not_found",
"dark_flute_boy_after_shovel_get",
"shop_fortune_teller_lw_hint_0",
"shop_fortune_teller_lw_hint_1",
"shop_fortune_teller_lw_hint_2",
"shop_fortune_teller_lw_hint_3",
"shop_fortune_teller_lw_hint_4",
"shop_fortune_teller_lw_hint_5",
"shop_fortune_teller_lw_hint_6",
"shop_fortune_teller_lw_hint_7",
"shop_fortune_teller_lw_no_rupees",
"shop_fortune_teller_lw",
"shop_fortune_teller_lw_post_hint",
"shop_fortune_teller_lw_no",
"shop_fortune_teller_lw_hint_8",
"shop_fortune_teller_lw_hint_9",
"shop_fortune_teller_lw_hint_10",
"shop_fortune_teller_lw_hint_11",
"shop_fortune_teller_lw_hint_12",
"shop_fortune_teller_lw_hint_13",
"shop_fortune_teller_lw_hint_14",
"shop_fortune_teller_lw_hint_15",
"dark_sanctuary",
"dark_sanctuary_hint_0",
"dark_sanctuary_no",
"dark_sanctuary_hint_1",
"dark_sanctuary_yes",
"dark_sanctuary_hint_2",
"sick_kid_no_bottle",
"sick_kid_trade",
"sick_kid_post_trade",
"desert_thief_sitting",
"desert_thief_following",
"desert_thief_question",
"desert_thief_question_yes",
"desert_thief_after_item_get",
"desert_thief_reassure",
"hylian_text_3",
"tablet_ether_book",
"tablet_bombos_book",
"magic_bat_wake",
"magic_bat_give_half_magic",
"intro_main",
"intro_throne_room",
"intro_zelda_cell",
"intro_agahnim",
"pickup_purple_chest",
"bomb_shop",
"bomb_shop_big_bomb",
"bomb_shop_big_bomb_buy",
"item_get_big_bomb",
"kiki_second_extortion",
"kiki_second_extortion_no",
"kiki_second_extortion_yes",
"kiki_first_extortion",
"kiki_first_extortion_yes",
"kiki_first_extortion_no",
"kiki_leaving_screen",
"blind_in_the_cell",
"blind_by_the_light",
"blind_not_that_way",
"aginah_l1sword_no_book",
"aginah_l1sword_with_pendants",
"aginah",
"aginah_need_better_sword",
"aginah_have_better_sword",
"catfish",
"catfish_after_item",
"lumberjack_right",
"lumberjack_left",
"lumberjack_left_post_agahnim",
"fighting_brothers_right",
"fighting_brothers_right_opened",
"fighting_brothers_left",
"maiden_crystal_1",
"maiden_crystal_2",
"maiden_crystal_3",
"maiden_crystal_4",
"maiden_crystal_5",
"maiden_crystal_6",
"maiden_crystal_7",
"maiden_ending",
"maiden_confirm_understood",
"barrier_breaking",
"maiden_crystal_7_again",
"agahnim_zelda_teleport",
"agahnim_magic_running_away",
"agahnim_hide_and_seek_found",
"agahnim_defeated",
"agahnim_final_meeting",
"zora_meeting",
"zora_tells_cost",
"zora_get_flippers",
"zora_no_cash",
"zora_no_buy_item",
"kakariko_saharalasa_grandson",
"kakariko_saharalasa_grandson_next",
"dark_palace_tree_dude",
"fairy_wishing_ponds",
"fairy_wishing_ponds_no",
"pond_of_wishing_no",
"pond_of_wishing_return_item",
"pond_of_wishing_throw",
"pond_pre_item_silvers",
"pond_of_wishing_great_luck",
"pond_of_wishing_good_luck",
"pond_of_wishing_meh_luck",
"pond_of_wishing_bad_luck",
"pond_of_wishing_fortune",
"item_get_14_heart",
"item_get_24_heart",
"item_get_34_heart",
"item_get_whole_heart",
"item_get_sanc_heart",
"fairy_fountain_refill",
"death_mountain_bullied_no_pearl",
"death_mountain_bullied_with_pearl",
"death_mountain_bully_no_pearl",
"death_mountain_bully_with_pearl",
"shop_darkworld_enter",
"game_chest_village_of_outcasts",
"game_chest_no_cash",
"game_chest_not_played",
"game_chest_played",
"game_chest_village_of_outcasts_play",
"shop_first_time",
"shop_already_have",
"shop_buy_shield",
"shop_buy_red_potion",
"shop_buy_arrows",
"shop_buy_bombs",
"shop_buy_bee",
"shop_buy_heart",
"shop_first_no_bottle_buy",
"shop_buy_no_space",
"ganon_fall_in",
"ganon_phase_3",
"lost_woods_thief",
"blinds_hut_dude",
"end_triforce",
"toppi_fallen",
"kakariko_tavern_fisherman",
"thief_money",
"thief_desert_rupee_cave",
"thief_ice_rupee_cave",
"telepathic_tile_south_east_darkworld_cave",
"cukeman",
"cukeman_2",
"potion_shop_no_cash",
"kakariko_powdered_chicken",
"game_chest_south_of_kakariko",
"game_chest_play_yes",
"game_chest_play_no",
"game_chest_lost_woods",
"kakariko_flophouse_man_no_flippers",
"kakariko_flophouse_man",
"menu_start_2",
"menu_start_3",
"menu_pause",
"game_digging_choice",
"game_digging_start",
"game_digging_no_cash",
"game_digging_end_time",
"game_digging_come_back_later",
"game_digging_no_follower",
"menu_start_4",
"ganon_fall_in_alt",
"ganon_phase_3_alt",
"sign_east_death_mountain_bridge",
"fish_money",
"sign_ganons_tower",
"sign_ganon",
"ganon_phase_3_no_bow",
"ganon_phase_3_no_silvers_alt",
"ganon_phase_3_no_silvers",
"ganon_phase_3_silvers",
"murahdahla",
]
def __init__(self): def __init__(self):
self._text = OrderedDict() self._text = OrderedDict()
self.setDefaultText() self.setDefaultText()

View File

@@ -213,7 +213,6 @@ class ALTTPWorld(World):
item_name_to_id = {name: data.item_code for name, data in item_table.items() if type(data.item_code) == int} item_name_to_id = {name: data.item_code for name, data in item_table.items() if type(data.item_code) == int}
location_name_to_id = lookup_name_to_id location_name_to_id = lookup_name_to_id
data_version = 9
required_client_version = (0, 4, 1) required_client_version = (0, 4, 1)
web = ALTTPWeb() web = ALTTPWeb()

View File

@@ -0,0 +1,34 @@
from typing import Dict
from BaseClasses import Tutorial
from ..AutoWorld import WebWorld, World
class AP_SudokuWebWorld(WebWorld):
options_page = "games/Sudoku/info/en"
theme = 'partyTime'
setup_en = Tutorial(
tutorial_name='Setup Guide',
description='A guide to playing APSudoku',
language='English',
file_name='setup_en.md',
link='setup/en',
authors=['EmilyV']
)
tutorials = [setup_en]
class AP_SudokuWorld(World):
"""
Play a little Sudoku while you're in BK mode to maybe get some useful hints
"""
game = "Sudoku"
web = AP_SudokuWebWorld()
item_name_to_id: Dict[str, int] = {}
location_name_to_id: Dict[str, int] = {}
@classmethod
def stage_assert_generate(cls, multiworld):
raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")

View File

@@ -0,0 +1,13 @@
# APSudoku
## Hint Games
HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot.
## What is this game?
Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random.
## Where is the options page?
There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.

View File

@@ -0,0 +1,37 @@
# APSudoku Setup Guide
## Required Software
- [APSudoku](https://github.com/EmilyV99/APSudoku)
- Windows (most tested on Win10)
- Other platforms might be able to build from source themselves; and may be included in the future.
## General Concept
This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
## Installation Procedures
Go to the latest release from the [APSudoku Releases page](https://github.com/EmilyV99/APSudoku/releases). Download and extract the `APSudoku.zip` file.
## Joining a MultiWorld Game
1. Run APSudoku.exe
2. Under the 'Archipelago' tab at the top-right:
- Enter the server url & port number
- Enter the name of the slot you wish to connect to
- Enter the room password (optional)
- Select DeathLink related settings (optional)
- Press connect
3. Go back to the 'Sudoku' tab
- Click the various '?' buttons for information on how to play / control
4. Choose puzzle difficulty
5. Try to solve the Sudoku. Click 'Check' when done.
## DeathLink Support
If 'DeathLink' is enabled when you click 'Connect':
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or quit a puzzle without solving it (including disconnecting).
- Life count customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
- On receiving a DeathLink from another player, your puzzle resets.

View File

@@ -77,41 +77,41 @@ class ItemData:
item_table = { item_table = {
# name: ID, Nb, Item Type, Item Group # name: ID, Nb, Item Type, Item Group
"Anemone": ItemData(698000, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_anemone "Anemone": ItemData(698000, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_anemone
"Arnassi statue": ItemData(698001, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_arnassi_statue "Arnassi Statue": ItemData(698001, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_arnassi_statue
"Big seed": ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed "Big Seed": ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed
"Glowing seed": ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed "Glowing Seed": ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed
"Black pearl": ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl "Black Pearl": ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl
"Baby blaster": ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster "Baby Blaster": ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster
"Crab armor": ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume "Crab Armor": ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume
"Baby dumbo": ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo "Baby Dumbo": ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo
"Tooth": ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss "Tooth": ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss
"Energy statue": ItemData(698009, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_statue "Energy Statue": ItemData(698009, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_statue
"Krotite armor": ItemData(698010, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_temple "Krotite Armor": ItemData(698010, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_temple
"Golden starfish": ItemData(698011, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_gold_star "Golden Starfish": ItemData(698011, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_gold_star
"Golden gear": ItemData(698012, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_golden_gear "Golden Gear": ItemData(698012, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_golden_gear
"Jelly beacon": ItemData(698013, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_beacon "Jelly Beacon": ItemData(698013, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_beacon
"Jelly costume": ItemData(698014, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_jelly_costume "Jelly Costume": ItemData(698014, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_jelly_costume
"Jelly plant": ItemData(698015, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_plant "Jelly Plant": ItemData(698015, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_plant
"Mithalas doll": ItemData(698016, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithala_doll "Mithalas Doll": ItemData(698016, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithala_doll
"Mithalan dress": ItemData(698017, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalan_costume "Mithalan Dress": ItemData(698017, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalan_costume
"Mithalas banner": ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner "Mithalas Banner": ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner
"Mithalas pot": ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot "Mithalas Pot": ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot
"Mutant costume": ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume "Mutant Costume": ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume
"Baby nautilus": ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus "Baby Nautilus": ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus
"Baby piranha": ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha "Baby Piranha": ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha
"Arnassi Armor": ItemData(698023, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_seahorse_costume "Arnassi Armor": ItemData(698023, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_seahorse_costume
"Seed bag": ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag "Seed Bag": ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag
"King's Skull": ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull "King's Skull": ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull
"Song plant spore": ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed "Song Plant Spore": ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed
"Stone head": ItemData(698027, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_stone_head "Stone Head": ItemData(698027, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_stone_head
"Sun key": ItemData(698028, 1, ItemType.NORMAL, ItemGroup.COLLECTIBLE), # collectible_sun_key "Sun Key": ItemData(698028, 1, ItemType.NORMAL, ItemGroup.COLLECTIBLE), # collectible_sun_key
"Girl costume": ItemData(698029, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_teen_costume "Girl Costume": ItemData(698029, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_teen_costume
"Odd container": ItemData(698030, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_treasure_chest "Odd Container": ItemData(698030, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_treasure_chest
"Trident": ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head "Trident": ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head
"Turtle egg": ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg "Turtle Egg": ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg
"Jelly egg": ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed "Jelly Egg": ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed
"Urchin costume": ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume "Urchin Costume": ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume
"Baby walker": ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker "Baby Walker": ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker
"Vedha's Cure-All-All": ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All "Vedha's Cure-All-All": ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All
"Zuuna's perogi": ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi "Zuuna's perogi": ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi
"Arcane poultice": ItemData(698038, 7, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_arcanepoultice "Arcane poultice": ItemData(698038, 7, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_arcanepoultice
@@ -206,9 +206,9 @@ item_table = {
"Transturtle Open Water top right": ItemData(698127, 1, ItemType.PROGRESSION, "Transturtle Open Water top right": ItemData(698127, 1, ItemType.PROGRESSION,
ItemGroup.TURTLE), # transport_openwater03 ItemGroup.TURTLE), # transport_openwater03
"Transturtle Forest bottom left": ItemData(698128, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest04 "Transturtle Forest bottom left": ItemData(698128, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest04
"Transturtle Home water": ItemData(698129, 1, ItemType.NORMAL, ItemGroup.TURTLE), # transport_mainarea "Transturtle Home Water": ItemData(698129, 1, ItemType.NORMAL, ItemGroup.TURTLE), # transport_mainarea
"Transturtle Abyss right": ItemData(698130, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_abyss03 "Transturtle Abyss right": ItemData(698130, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_abyss03
"Transturtle Final Boss": ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss "Transturtle Final Boss": ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss
"Transturtle Simon says": ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05 "Transturtle Simon Says": ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05
"Transturtle Arnassi ruins": ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse "Transturtle Arnassi Ruins": ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse
} }

View File

@@ -29,213 +29,213 @@ class AquariaLocation(Location):
class AquariaLocations: class AquariaLocations:
locations_verse_cave_r = { locations_verse_cave_r = {
"Verse cave, bulb in the skeleton room": 698107, "Verse Cave, bulb in the skeleton room": 698107,
"Verse cave, bulb in the path left of the skeleton room": 698108, "Verse Cave, bulb in the path left of the skeleton room": 698108,
"Verse cave right area, Big Seed": 698175, "Verse Cave right area, Big Seed": 698175,
} }
locations_verse_cave_l = { locations_verse_cave_l = {
"Verse cave, the Naija hint about here shield ability": 698200, "Verse Cave, the Naija hint about the shield ability": 698200,
"Verse cave left area, bulb in the center part": 698021, "Verse Cave left area, bulb in the center part": 698021,
"Verse cave left area, bulb in the right part": 698022, "Verse Cave left area, bulb in the right part": 698022,
"Verse cave left area, bulb under the rock at the end of the path": 698023, "Verse Cave left area, bulb under the rock at the end of the path": 698023,
} }
locations_home_water = { locations_home_water = {
"Home water, bulb below the grouper fish": 698058, "Home Water, bulb below the grouper fish": 698058,
"Home water, bulb in the path below Nautilus Prime": 698059, "Home Water, bulb in the path below Nautilus Prime": 698059,
"Home water, bulb in the little room above the grouper fish": 698060, "Home Water, bulb in the little room above the grouper fish": 698060,
"Home water, bulb in the end of the left path from the verse cave": 698061, "Home Water, bulb in the end of the left path from the Verse Cave": 698061,
"Home water, bulb in the top left path": 698062, "Home Water, bulb in the top left path": 698062,
"Home water, bulb in the bottom left room": 698063, "Home Water, bulb in the bottom left room": 698063,
"Home water, bulb close to the Naija's home": 698064, "Home Water, bulb close to Naija's Home": 698064,
"Home water, bulb under the rock in the left path from the verse cave": 698065, "Home Water, bulb under the rock in the left path from the Verse Cave": 698065,
} }
locations_home_water_nautilus = { locations_home_water_nautilus = {
"Home water, Nautilus Egg": 698194, "Home Water, Nautilus Egg": 698194,
} }
locations_home_water_transturtle = { locations_home_water_transturtle = {
"Home water, Transturtle": 698213, "Home Water, Transturtle": 698213,
} }
locations_naija_home = { locations_naija_home = {
"Naija's home, bulb after the energy door": 698119, "Naija's Home, bulb after the energy door": 698119,
"Naija's home, bulb under the rock at the right of the main path": 698120, "Naija's Home, bulb under the rock at the right of the main path": 698120,
} }
locations_song_cave = { locations_song_cave = {
"Song cave, Erulian spirit": 698206, "Song Cave, Erulian spirit": 698206,
"Song cave, bulb in the top left part": 698071, "Song Cave, bulb in the top left part": 698071,
"Song cave, bulb in the big anemone room": 698072, "Song Cave, bulb in the big anemone room": 698072,
"Song cave, bulb in the path to the singing statues": 698073, "Song Cave, bulb in the path to the singing statues": 698073,
"Song cave, bulb under the rock in the path to the singing statues": 698074, "Song Cave, bulb under the rock in the path to the singing statues": 698074,
"Song cave, bulb under the rock close to the song door": 698075, "Song Cave, bulb under the rock close to the song door": 698075,
"Song cave, Verse egg": 698160, "Song Cave, Verse Egg": 698160,
"Song cave, Jelly beacon": 698178, "Song Cave, Jelly Beacon": 698178,
"Song cave, Anemone seed": 698162, "Song Cave, Anemone Seed": 698162,
} }
locations_energy_temple_1 = { locations_energy_temple_1 = {
"Energy temple first area, beating the energy statue": 698205, "Energy Temple first area, beating the Energy Statue": 698205,
"Energy temple first area, bulb in the bottom room blocked by a rock": 698027, "Energy Temple first area, bulb in the bottom room blocked by a rock": 698027,
} }
locations_energy_temple_idol = { locations_energy_temple_idol = {
"Energy temple first area, Energy Idol": 698170, "Energy Temple first area, Energy Idol": 698170,
} }
locations_energy_temple_2 = { locations_energy_temple_2 = {
"Energy temple second area, bulb under the rock": 698028, "Energy Temple second area, bulb under the rock": 698028,
} }
locations_energy_temple_altar = { locations_energy_temple_altar = {
"Energy temple bottom entrance, Krotite armor": 698163, "Energy Temple bottom entrance, Krotite Armor": 698163,
} }
locations_energy_temple_3 = { locations_energy_temple_3 = {
"Energy temple third area, bulb in the bottom path": 698029, "Energy Temple third area, bulb in the bottom path": 698029,
} }
locations_energy_temple_boss = { locations_energy_temple_boss = {
"Energy temple boss area, Fallen god tooth": 698169, "Energy Temple boss area, Fallen God Tooth": 698169,
} }
locations_energy_temple_blaster_room = { locations_energy_temple_blaster_room = {
"Energy temple blaster room, Blaster egg": 698195, "Energy Temple blaster room, Blaster Egg": 698195,
} }
locations_openwater_tl = { locations_openwater_tl = {
"Open water top left area, bulb under the rock in the right path": 698001, "Open Water top left area, bulb under the rock in the right path": 698001,
"Open water top left area, bulb under the rock in the left path": 698002, "Open Water top left area, bulb under the rock in the left path": 698002,
"Open water top left area, bulb to the right of the save cristal": 698003, "Open Water top left area, bulb to the right of the save crystal": 698003,
} }
locations_openwater_tr = { locations_openwater_tr = {
"Open water top right area, bulb in the small path before Mithalas": 698004, "Open Water top right area, bulb in the small path before Mithalas": 698004,
"Open water top right area, bulb in the path from the left entrance": 698005, "Open Water top right area, bulb in the path from the left entrance": 698005,
"Open water top right area, bulb in the clearing close to the bottom exit": 698006, "Open Water top right area, bulb in the clearing close to the bottom exit": 698006,
"Open water top right area, bulb in the big clearing close to the save cristal": 698007, "Open Water top right area, bulb in the big clearing close to the save crystal": 698007,
"Open water top right area, bulb in the big clearing to the top exit": 698008, "Open Water top right area, bulb in the big clearing to the top exit": 698008,
"Open water top right area, first urn in the Mithalas exit": 698148, "Open Water top right area, first urn in the Mithalas exit": 698148,
"Open water top right area, second urn in the Mithalas exit": 698149, "Open Water top right area, second urn in the Mithalas exit": 698149,
"Open water top right area, third urn in the Mithalas exit": 698150, "Open Water top right area, third urn in the Mithalas exit": 698150,
} }
locations_openwater_tr_turtle = { locations_openwater_tr_turtle = {
"Open water top right area, bulb in the turtle room": 698009, "Open Water top right area, bulb in the turtle room": 698009,
"Open water top right area, Transturtle": 698211, "Open Water top right area, Transturtle": 698211,
} }
locations_openwater_bl = { locations_openwater_bl = {
"Open water bottom left area, bulb behind the chomper fish": 698011, "Open Water bottom left area, bulb behind the chomper fish": 698011,
"Open water bottom left area, bulb inside the lowest fish pass": 698010, "Open Water bottom left area, bulb inside the lowest fish pass": 698010,
} }
locations_skeleton_path = { locations_skeleton_path = {
"Open water skeleton path, bulb close to the right exit": 698012, "Open Water skeleton path, bulb close to the right exit": 698012,
"Open water skeleton path, bulb behind the chomper fish": 698013, "Open Water skeleton path, bulb behind the chomper fish": 698013,
} }
locations_skeleton_path_sc = { locations_skeleton_path_sc = {
"Open water skeleton path, King skull": 698177, "Open Water skeleton path, King Skull": 698177,
} }
locations_arnassi = { locations_arnassi = {
"Arnassi Ruins, bulb in the right part": 698014, "Arnassi Ruins, bulb in the right part": 698014,
"Arnassi Ruins, bulb in the left part": 698015, "Arnassi Ruins, bulb in the left part": 698015,
"Arnassi Ruins, bulb in the center part": 698016, "Arnassi Ruins, bulb in the center part": 698016,
"Arnassi ruins, Song plant spore on the top of the ruins": 698179, "Arnassi Ruins, Song Plant Spore": 698179,
"Arnassi ruins, Arnassi Armor": 698191, "Arnassi Ruins, Arnassi Armor": 698191,
} }
locations_arnassi_path = { locations_arnassi_path = {
"Arnassi Ruins, Arnassi statue": 698164, "Arnassi Ruins, Arnassi Statue": 698164,
"Arnassi Ruins, Transturtle": 698217, "Arnassi Ruins, Transturtle": 698217,
} }
locations_arnassi_crab_boss = { locations_arnassi_crab_boss = {
"Arnassi ruins, Crab armor": 698187, "Arnassi Ruins, Crab Armor": 698187,
} }
locations_simon = { locations_simon = {
"Kelp forest, beating Simon says": 698156, "Simon Says area, beating Simon Says": 698156,
"Simon says area, Transturtle": 698216, "Simon Says area, Transturtle": 698216,
} }
locations_mithalas_city = { locations_mithalas_city = {
"Mithalas city, first bulb in the left city part": 698030, "Mithalas City, first bulb in the left city part": 698030,
"Mithalas city, second bulb in the left city part": 698035, "Mithalas City, second bulb in the left city part": 698035,
"Mithalas city, bulb in the right part": 698031, "Mithalas City, bulb in the right part": 698031,
"Mithalas city, bulb at the top of the city": 698033, "Mithalas City, bulb at the top of the city": 698033,
"Mithalas city, first bulb in a broken home": 698034, "Mithalas City, first bulb in a broken home": 698034,
"Mithalas city, second bulb in a broken home": 698041, "Mithalas City, second bulb in a broken home": 698041,
"Mithalas city, bulb in the bottom left part": 698037, "Mithalas City, bulb in the bottom left part": 698037,
"Mithalas city, first bulb in one of the homes": 698038, "Mithalas City, first bulb in one of the homes": 698038,
"Mithalas city, second bulb in one of the homes": 698039, "Mithalas City, second bulb in one of the homes": 698039,
"Mithalas city, first urn in one of the homes": 698123, "Mithalas City, first urn in one of the homes": 698123,
"Mithalas city, second urn in one of the homes": 698124, "Mithalas City, second urn in one of the homes": 698124,
"Mithalas city, first urn in the city reserve": 698125, "Mithalas City, first urn in the city reserve": 698125,
"Mithalas city, second urn in the city reserve": 698126, "Mithalas City, second urn in the city reserve": 698126,
"Mithalas city, third urn in the city reserve": 698127, "Mithalas City, third urn in the city reserve": 698127,
} }
locations_mithalas_city_top_path = { locations_mithalas_city_top_path = {
"Mithalas city, first bulb at the end of the top path": 698032, "Mithalas City, first bulb at the end of the top path": 698032,
"Mithalas city, second bulb at the end of the top path": 698040, "Mithalas City, second bulb at the end of the top path": 698040,
"Mithalas city, bulb in the top path": 698036, "Mithalas City, bulb in the top path": 698036,
"Mithalas city, Mithalas pot": 698174, "Mithalas City, Mithalas Pot": 698174,
"Mithalas city, urn in the cathedral flower tube entrance": 698128, "Mithalas City, urn in the Castle flower tube entrance": 698128,
} }
locations_mithalas_city_fishpass = { locations_mithalas_city_fishpass = {
"Mithalas city, Doll": 698173, "Mithalas City, Doll": 698173,
"Mithalas city, urn inside a home fish pass": 698129, "Mithalas City, urn inside a home fish pass": 698129,
} }
locations_cathedral_l = { locations_cathedral_l = {
"Mithalas city castle, bulb in the flesh hole": 698042, "Mithalas City Castle, bulb in the flesh hole": 698042,
"Mithalas city castle, Blue banner": 698165, "Mithalas City Castle, Blue banner": 698165,
"Mithalas city castle, urn in the bedroom": 698130, "Mithalas City Castle, urn in the bedroom": 698130,
"Mithalas city castle, first urn of the single lamp path": 698131, "Mithalas City Castle, first urn of the single lamp path": 698131,
"Mithalas city castle, second urn of the single lamp path": 698132, "Mithalas City Castle, second urn of the single lamp path": 698132,
"Mithalas city castle, urn in the bottom room": 698133, "Mithalas City Castle, urn in the bottom room": 698133,
"Mithalas city castle, first urn on the entrance path": 698134, "Mithalas City Castle, first urn on the entrance path": 698134,
"Mithalas city castle, second urn on the entrance path": 698135, "Mithalas City Castle, second urn on the entrance path": 698135,
} }
locations_cathedral_l_tube = { locations_cathedral_l_tube = {
"Mithalas castle, beating the priests": 698208, "Mithalas City Castle, beating the Priests": 698208,
} }
locations_cathedral_l_sc = { locations_cathedral_l_sc = {
"Mithalas city castle, Trident head": 698183, "Mithalas City Castle, Trident Head": 698183,
} }
locations_cathedral_r = { locations_cathedral_r = {
"Mithalas cathedral, first urn in the top right room": 698136, "Mithalas Cathedral, first urn in the top right room": 698136,
"Mithalas cathedral, second urn in the top right room": 698137, "Mithalas Cathedral, second urn in the top right room": 698137,
"Mithalas cathedral, third urn in the top right room": 698138, "Mithalas Cathedral, third urn in the top right room": 698138,
"Mithalas cathedral, urn in the flesh room with fleas": 698139, "Mithalas Cathedral, urn in the flesh room with fleas": 698139,
"Mithalas cathedral, first urn in the bottom right path": 698140, "Mithalas Cathedral, first urn in the bottom right path": 698140,
"Mithalas cathedral, second urn in the bottom right path": 698141, "Mithalas Cathedral, second urn in the bottom right path": 698141,
"Mithalas cathedral, urn behind the flesh vein": 698142, "Mithalas Cathedral, urn behind the flesh vein": 698142,
"Mithalas cathedral, urn in the top left eyes boss room": 698143, "Mithalas Cathedral, urn in the top left eyes boss room": 698143,
"Mithalas cathedral, first urn in the path behind the flesh vein": 698144, "Mithalas Cathedral, first urn in the path behind the flesh vein": 698144,
"Mithalas cathedral, second urn in the path behind the flesh vein": 698145, "Mithalas Cathedral, second urn in the path behind the flesh vein": 698145,
"Mithalas cathedral, third urn in the path behind the flesh vein": 698146, "Mithalas Cathedral, third urn in the path behind the flesh vein": 698146,
"Mithalas cathedral, one of the urns in the top right room": 698147, "Mithalas Cathedral, fourth urn in the top right room": 698147,
"Mithalas cathedral, Mithalan Dress": 698189, "Mithalas Cathedral, Mithalan Dress": 698189,
"Mithalas cathedral right area, urn below the left entrance": 698198, "Mithalas Cathedral right area, urn below the left entrance": 698198,
} }
locations_cathedral_underground = { locations_cathedral_underground = {
"Cathedral underground, bulb in the center part": 698113, "Cathedral Underground, bulb in the center part": 698113,
"Cathedral underground, first bulb in the top left part": 698114, "Cathedral Underground, first bulb in the top left part": 698114,
"Cathedral underground, second bulb in the top left part": 698115, "Cathedral Underground, second bulb in the top left part": 698115,
"Cathedral underground, third bulb in the top left part": 698116, "Cathedral Underground, third bulb in the top left part": 698116,
"Cathedral underground, bulb close to the save cristal": 698117, "Cathedral Underground, bulb close to the save crystal": 698117,
"Cathedral underground, bulb in the bottom right path": 698118, "Cathedral Underground, bulb in the bottom right path": 698118,
} }
locations_cathedral_boss = { locations_cathedral_boss = {
@@ -246,12 +246,12 @@ class AquariaLocations:
"Kelp Forest top left area, bulb in the bottom left clearing": 698044, "Kelp Forest top left area, bulb in the bottom left clearing": 698044,
"Kelp Forest top left area, bulb in the path down from the top left clearing": 698045, "Kelp Forest top left area, bulb in the path down from the top left clearing": 698045,
"Kelp Forest top left area, bulb in the top left clearing": 698046, "Kelp Forest top left area, bulb in the top left clearing": 698046,
"Kelp Forest top left, Jelly Egg": 698185, "Kelp Forest top left area, Jelly Egg": 698185,
} }
locations_forest_tl_fp = { locations_forest_tl_fp = {
"Kelp Forest top left area, bulb close to the Verse egg": 698047, "Kelp Forest top left area, bulb close to the Verse Egg": 698047,
"Kelp forest top left area, Verse egg": 698158, "Kelp Forest top left area, Verse Egg": 698158,
} }
locations_forest_tr = { locations_forest_tr = {
@@ -260,7 +260,7 @@ class AquariaLocations:
"Kelp Forest top right area, bulb in the left path's big room": 698051, "Kelp Forest top right area, bulb in the left path's big room": 698051,
"Kelp Forest top right area, bulb in the left path's small room": 698052, "Kelp Forest top right area, bulb in the left path's small room": 698052,
"Kelp Forest top right area, bulb at the top of the center clearing": 698053, "Kelp Forest top right area, bulb at the top of the center clearing": 698053,
"Kelp forest top right area, Black pearl": 698167, "Kelp Forest top right area, Black Pearl": 698167,
} }
locations_forest_tr_fp = { locations_forest_tr_fp = {
@@ -269,16 +269,16 @@ class AquariaLocations:
locations_forest_bl = { locations_forest_bl = {
"Kelp Forest bottom left area, bulb close to the spirit crystals": 698054, "Kelp Forest bottom left area, bulb close to the spirit crystals": 698054,
"Kelp forest bottom left area, Walker baby": 698186, "Kelp Forest bottom left area, Walker baby": 698186,
"Kelp Forest bottom left area, Transturtle": 698212, "Kelp Forest bottom left area, Transturtle": 698212,
} }
locations_forest_br = { locations_forest_br = {
"Kelp forest bottom right area, Odd Container": 698168, "Kelp Forest bottom right area, Odd Container": 698168,
} }
locations_forest_boss = { locations_forest_boss = {
"Kelp forest boss area, beating Drunian God": 698204, "Kelp Forest boss area, beating Drunian God": 698204,
} }
locations_forest_boss_entrance = { locations_forest_boss_entrance = {
@@ -286,7 +286,7 @@ class AquariaLocations:
} }
locations_forest_fish_cave = { locations_forest_fish_cave = {
"Kelp Forest bottom left area, Fish cave puzzle": 698207, "Kelp Forest bottom left area, Fish Cave puzzle": 698207,
} }
locations_forest_sprite_cave = { locations_forest_sprite_cave = {
@@ -295,7 +295,7 @@ class AquariaLocations:
locations_forest_sprite_cave_tube = { locations_forest_sprite_cave_tube = {
"Kelp Forest sprite cave, bulb in the second room": 698057, "Kelp Forest sprite cave, bulb in the second room": 698057,
"Kelp Forest Sprite Cave, Seed bag": 698176, "Kelp Forest sprite cave, Seed Bag": 698176,
} }
locations_mermog_cave = { locations_mermog_cave = {
@@ -307,14 +307,14 @@ class AquariaLocations:
} }
locations_veil_tl = { locations_veil_tl = {
"The veil top left area, In the Li cave": 698199, "The Veil top left area, In Li's cave": 698199,
"The veil top left area, bulb under the rock in the top right path": 698078, "The Veil top left area, bulb under the rock in the top right path": 698078,
"The veil top left area, bulb hidden behind the blocking rock": 698076, "The Veil top left area, bulb hidden behind the blocking rock": 698076,
"The veil top left area, Transturtle": 698209, "The Veil top left area, Transturtle": 698209,
} }
locations_veil_tl_fp = { locations_veil_tl_fp = {
"The veil top left area, bulb inside the fish pass": 698077, "The Veil top left area, bulb inside the fish pass": 698077,
} }
locations_turtle_cave = { locations_turtle_cave = {
@@ -322,56 +322,56 @@ class AquariaLocations:
} }
locations_turtle_cave_bubble = { locations_turtle_cave_bubble = {
"Turtle cave, bulb in bubble cliff": 698000, "Turtle cave, bulb in Bubble Cliff": 698000,
"Turtle cave, Urchin costume": 698193, "Turtle cave, Urchin Costume": 698193,
} }
locations_veil_tr_r = { locations_veil_tr_r = {
"The veil top right area, bulb in the middle of the wall jump cliff": 698079, "The Veil top right area, bulb in the middle of the wall jump cliff": 698079,
"The veil top right area, golden starfish at the bottom right of the bottom path": 698180, "The Veil top right area, Golden Starfish": 698180,
} }
locations_veil_tr_l = { locations_veil_tr_l = {
"The veil top right area, bulb in the top of the water fall": 698080, "The Veil top right area, bulb at the top of the waterfall": 698080,
"The veil top right area, Transturtle": 698210, "The Veil top right area, Transturtle": 698210,
} }
locations_veil_bl = { locations_veil_bl = {
"The veil bottom area, bulb in the left path": 698082, "The Veil bottom area, bulb in the left path": 698082,
} }
locations_veil_b_sc = { locations_veil_b_sc = {
"The veil bottom area, bulb in the spirit path": 698081, "The Veil bottom area, bulb in the spirit path": 698081,
} }
locations_veil_bl_fp = { locations_veil_bl_fp = {
"The veil bottom area, Verse egg": 698157, "The Veil bottom area, Verse Egg": 698157,
} }
locations_veil_br = { locations_veil_br = {
"The veil bottom area, Stone Head": 698181, "The Veil bottom area, Stone Head": 698181,
} }
locations_octo_cave_t = { locations_octo_cave_t = {
"Octopus cave, Dumbo Egg": 698196, "Octopus Cave, Dumbo Egg": 698196,
} }
locations_octo_cave_b = { locations_octo_cave_b = {
"Octopus cave, bulb in the path below the octopus cave path": 698122, "Octopus Cave, bulb in the path below the Octopus Cave path": 698122,
} }
locations_sun_temple_l = { locations_sun_temple_l = {
"Sun temple, bulb in the top left part": 698094, "Sun Temple, bulb in the top left part": 698094,
"Sun temple, bulb in the top right part": 698095, "Sun Temple, bulb in the top right part": 698095,
"Sun temple, bulb at the top of the high dark room": 698096, "Sun Temple, bulb at the top of the high dark room": 698096,
"Sun temple, Golden Gear": 698171, "Sun Temple, Golden Gear": 698171,
} }
locations_sun_temple_r = { locations_sun_temple_r = {
"Sun temple, first bulb of the temple": 698091, "Sun Temple, first bulb of the temple": 698091,
"Sun temple, bulb on the left part": 698092, "Sun Temple, bulb on the left part": 698092,
"Sun temple, bulb in the hidden room of the right part": 698093, "Sun Temple, bulb in the hidden room of the right part": 698093,
"Sun temple, Sun key": 698182, "Sun Temple, Sun Key": 698182,
} }
locations_sun_temple_boss_path = { locations_sun_temple_boss_path = {
@@ -382,13 +382,13 @@ class AquariaLocations:
} }
locations_sun_temple_boss = { locations_sun_temple_boss = {
"Sun temple boss area, beating Sun God": 698203, "Sun Temple boss area, beating Sun God": 698203,
} }
locations_abyss_l = { locations_abyss_l = {
"Abyss left area, bulb in hidden path room": 698024, "Abyss left area, bulb in hidden path room": 698024,
"Abyss left area, bulb in the right part": 698025, "Abyss left area, bulb in the right part": 698025,
"Abyss left area, Glowing seed": 698166, "Abyss left area, Glowing Seed": 698166,
"Abyss left area, Glowing Plant": 698172, "Abyss left area, Glowing Plant": 698172,
} }
@@ -405,87 +405,87 @@ class AquariaLocations:
} }
locations_ice_cave = { locations_ice_cave = {
"Ice cave, bulb in the room to the right": 698083, "Ice Cave, bulb in the room to the right": 698083,
"Ice cave, First bulbs in the top exit room": 698084, "Ice Cave, first bulb in the top exit room": 698084,
"Ice cave, Second bulbs in the top exit room": 698085, "Ice Cave, second bulb in the top exit room": 698085,
"Ice cave, third bulbs in the top exit room": 698086, "Ice Cave, third bulb in the top exit room": 698086,
"Ice cave, bulb in the left room": 698087, "Ice Cave, bulb in the left room": 698087,
} }
locations_bubble_cave = { locations_bubble_cave = {
"Bubble cave, bulb in the left cave wall": 698089, "Bubble Cave, bulb in the left cave wall": 698089,
"Bubble cave, bulb in the right cave wall (behind the ice cristal)": 698090, "Bubble Cave, bulb in the right cave wall (behind the ice crystal)": 698090,
} }
locations_bubble_cave_boss = { locations_bubble_cave_boss = {
"Bubble cave, Verse egg": 698161, "Bubble Cave, Verse Egg": 698161,
} }
locations_king_jellyfish_cave = { locations_king_jellyfish_cave = {
"King Jellyfish cave, bulb in the right path from King Jelly": 698088, "King Jellyfish Cave, bulb in the right path from King Jelly": 698088,
"King Jellyfish cave, Jellyfish Costume": 698188, "King Jellyfish Cave, Jellyfish Costume": 698188,
} }
locations_whale = { locations_whale = {
"The whale, Verse egg": 698159, "The Whale, Verse Egg": 698159,
} }
locations_sunken_city_r = { locations_sunken_city_r = {
"Sunken city right area, crate close to the save cristal": 698154, "Sunken City right area, crate close to the save crystal": 698154,
"Sunken city right area, crate in the left bottom room": 698155, "Sunken City right area, crate in the left bottom room": 698155,
} }
locations_sunken_city_l = { locations_sunken_city_l = {
"Sunken city left area, crate in the little pipe room": 698151, "Sunken City left area, crate in the little pipe room": 698151,
"Sunken city left area, crate close to the save cristal": 698152, "Sunken City left area, crate close to the save crystal": 698152,
"Sunken city left area, crate before the bedroom": 698153, "Sunken City left area, crate before the bedroom": 698153,
} }
locations_sunken_city_l_bedroom = { locations_sunken_city_l_bedroom = {
"Sunken city left area, Girl Costume": 698192, "Sunken City left area, Girl Costume": 698192,
} }
locations_sunken_city_boss = { locations_sunken_city_boss = {
"Sunken city, bulb on the top of the boss area (boiler room)": 698043, "Sunken City, bulb on top of the boss area": 698043,
} }
locations_body_c = { locations_body_c = {
"The body center area, breaking li cage": 698201, "The Body center area, breaking Li's cage": 698201,
"The body main area, bulb on the main path blocking tube": 698097, "The Body main area, bulb on the main path blocking tube": 698097,
} }
locations_body_l = { locations_body_l = {
"The body left area, first bulb in the top face room": 698066, "The Body left area, first bulb in the top face room": 698066,
"The body left area, second bulb in the top face room": 698069, "The Body left area, second bulb in the top face room": 698069,
"The body left area, bulb below the water stream": 698067, "The Body left area, bulb below the water stream": 698067,
"The body left area, bulb in the top path to the top face room": 698068, "The Body left area, bulb in the top path to the top face room": 698068,
"The body left area, bulb in the bottom face room": 698070, "The Body left area, bulb in the bottom face room": 698070,
} }
locations_body_rt = { locations_body_rt = {
"The body right area, bulb in the top face room": 698100, "The Body right area, bulb in the top face room": 698100,
} }
locations_body_rb = { locations_body_rb = {
"The body right area, bulb in the top path to the bottom face room": 698098, "The Body right area, bulb in the top path to the bottom face room": 698098,
"The body right area, bulb in the bottom face room": 698099, "The Body right area, bulb in the bottom face room": 698099,
} }
locations_body_b = { locations_body_b = {
"The body bottom area, bulb in the Jelly Zap room": 698101, "The Body bottom area, bulb in the Jelly Zap room": 698101,
"The body bottom area, bulb in the nautilus room": 698102, "The Body bottom area, bulb in the nautilus room": 698102,
"The body bottom area, Mutant Costume": 698190, "The Body bottom area, Mutant Costume": 698190,
} }
locations_final_boss_tube = { locations_final_boss_tube = {
"Final boss area, first bulb in the turtle room": 698103, "Final Boss area, first bulb in the turtle room": 698103,
"Final boss area, second bulbs in the turtle room": 698104, "Final Boss area, second bulb in the turtle room": 698104,
"Final boss area, third bulbs in the turtle room": 698105, "Final Boss area, third bulb in the turtle room": 698105,
"Final boss area, Transturtle": 698215, "Final Boss area, Transturtle": 698215,
} }
locations_final_boss = { locations_final_boss = {
"Final boss area, bulb in the boss third form room": 698106, "Final Boss area, bulb in the boss third form room": 698106,
} }

View File

@@ -113,7 +113,7 @@ class BindSongNeededToGetUnderRockBulb(Toggle):
class UnconfineHomeWater(Choice): class UnconfineHomeWater(Choice):
""" """
Open the way out of the Home water area so that Naija can go to open water and beyond without the bind song. Open the way out of the Home Water area so that Naija can go to open water and beyond without the bind song.
""" """
display_name = "Unconfine Home Water Area" display_name = "Unconfine Home Water Area"
option_off = 0 option_off = 0

View File

@@ -36,8 +36,8 @@ def _has_li(state:CollectionState, player: int) -> bool:
def _has_damaging_item(state:CollectionState, player: int) -> bool: def _has_damaging_item(state:CollectionState, player: int) -> bool:
"""`player` in `state` has the shield song item""" """`player` in `state` has the shield song item"""
return state.has_any({"Energy form", "Nature form", "Beast form", "Li and Li song", "Baby nautilus", return state.has_any({"Energy form", "Nature form", "Beast form", "Li and Li song", "Baby Nautilus",
"Baby piranha", "Baby blaster"}, player) "Baby Piranha", "Baby Blaster"}, player)
def _has_shield_song(state:CollectionState, player: int) -> bool: def _has_shield_song(state:CollectionState, player: int) -> bool:
@@ -72,7 +72,7 @@ def _has_sun_form(state:CollectionState, player: int) -> bool:
def _has_light(state:CollectionState, player: int) -> bool: def _has_light(state:CollectionState, player: int) -> bool:
"""`player` in `state` has the light item""" """`player` in `state` has the light item"""
return state.has("Baby dumbo", player) or _has_sun_form(state, player) return state.has("Baby Dumbo", player) or _has_sun_form(state, player)
def _has_dual_form(state:CollectionState, player: int) -> bool: def _has_dual_form(state:CollectionState, player: int) -> bool:
@@ -237,26 +237,26 @@ class AquariaRegions:
AquariaLocations.locations_home_water_nautilus) AquariaLocations.locations_home_water_nautilus)
self.home_water_transturtle = self.__add_region("Home Water, turtle room", self.home_water_transturtle = self.__add_region("Home Water, turtle room",
AquariaLocations.locations_home_water_transturtle) AquariaLocations.locations_home_water_transturtle)
self.naija_home = self.__add_region("Naija's home", AquariaLocations.locations_naija_home) self.naija_home = self.__add_region("Naija's Home", AquariaLocations.locations_naija_home)
self.song_cave = self.__add_region("Song cave", AquariaLocations.locations_song_cave) self.song_cave = self.__add_region("Song Cave", AquariaLocations.locations_song_cave)
def __create_energy_temple(self) -> None: def __create_energy_temple(self) -> None:
""" """
Create the `energy_temple_*` regions Create the `energy_temple_*` regions
""" """
self.energy_temple_1 = self.__add_region("Energy temple first area", self.energy_temple_1 = self.__add_region("Energy Temple first area",
AquariaLocations.locations_energy_temple_1) AquariaLocations.locations_energy_temple_1)
self.energy_temple_2 = self.__add_region("Energy temple second area", self.energy_temple_2 = self.__add_region("Energy Temple second area",
AquariaLocations.locations_energy_temple_2) AquariaLocations.locations_energy_temple_2)
self.energy_temple_3 = self.__add_region("Energy temple third area", self.energy_temple_3 = self.__add_region("Energy Temple third area",
AquariaLocations.locations_energy_temple_3) AquariaLocations.locations_energy_temple_3)
self.energy_temple_altar = self.__add_region("Energy temple bottom entrance", self.energy_temple_altar = self.__add_region("Energy Temple bottom entrance",
AquariaLocations.locations_energy_temple_altar) AquariaLocations.locations_energy_temple_altar)
self.energy_temple_boss = self.__add_region("Energy temple fallen God room", self.energy_temple_boss = self.__add_region("Energy Temple fallen God room",
AquariaLocations.locations_energy_temple_boss) AquariaLocations.locations_energy_temple_boss)
self.energy_temple_idol = self.__add_region("Energy temple Idol room", self.energy_temple_idol = self.__add_region("Energy Temple Idol room",
AquariaLocations.locations_energy_temple_idol) AquariaLocations.locations_energy_temple_idol)
self.energy_temple_blaster_room = self.__add_region("Energy temple blaster room", self.energy_temple_blaster_room = self.__add_region("Energy Temple blaster room",
AquariaLocations.locations_energy_temple_blaster_room) AquariaLocations.locations_energy_temple_blaster_room)
def __create_openwater(self) -> None: def __create_openwater(self) -> None:
@@ -264,18 +264,18 @@ class AquariaRegions:
Create the `openwater_*`, `skeleton_path`, `arnassi*` and `simon` Create the `openwater_*`, `skeleton_path`, `arnassi*` and `simon`
regions regions
""" """
self.openwater_tl = self.__add_region("Open water top left area", self.openwater_tl = self.__add_region("Open Water top left area",
AquariaLocations.locations_openwater_tl) AquariaLocations.locations_openwater_tl)
self.openwater_tr = self.__add_region("Open water top right area", self.openwater_tr = self.__add_region("Open Water top right area",
AquariaLocations.locations_openwater_tr) AquariaLocations.locations_openwater_tr)
self.openwater_tr_turtle = self.__add_region("Open water top right area, turtle room", self.openwater_tr_turtle = self.__add_region("Open Water top right area, turtle room",
AquariaLocations.locations_openwater_tr_turtle) AquariaLocations.locations_openwater_tr_turtle)
self.openwater_bl = self.__add_region("Open water bottom left area", self.openwater_bl = self.__add_region("Open Water bottom left area",
AquariaLocations.locations_openwater_bl) AquariaLocations.locations_openwater_bl)
self.openwater_br = self.__add_region("Open water bottom right area", None) self.openwater_br = self.__add_region("Open Water bottom right area", None)
self.skeleton_path = self.__add_region("Open water skeleton path", self.skeleton_path = self.__add_region("Open Water skeleton path",
AquariaLocations.locations_skeleton_path) AquariaLocations.locations_skeleton_path)
self.skeleton_path_sc = self.__add_region("Open water skeleton path spirit cristal", self.skeleton_path_sc = self.__add_region("Open Water skeleton path spirit crystal",
AquariaLocations.locations_skeleton_path_sc) AquariaLocations.locations_skeleton_path_sc)
self.arnassi = self.__add_region("Arnassi Ruins", AquariaLocations.locations_arnassi) self.arnassi = self.__add_region("Arnassi Ruins", AquariaLocations.locations_arnassi)
self.arnassi_path = self.__add_region("Arnassi Ruins, back entrance path", self.arnassi_path = self.__add_region("Arnassi Ruins, back entrance path",
@@ -287,20 +287,20 @@ class AquariaRegions:
""" """
Create the `mithalas_city*` and `cathedral_*` regions Create the `mithalas_city*` and `cathedral_*` regions
""" """
self.mithalas_city = self.__add_region("Mithalas city", self.mithalas_city = self.__add_region("Mithalas City",
AquariaLocations.locations_mithalas_city) AquariaLocations.locations_mithalas_city)
self.mithalas_city_fishpass = self.__add_region("Mithalas city fish pass", self.mithalas_city_fishpass = self.__add_region("Mithalas City fish pass",
AquariaLocations.locations_mithalas_city_fishpass) AquariaLocations.locations_mithalas_city_fishpass)
self.mithalas_city_top_path = self.__add_region("Mithalas city top path", self.mithalas_city_top_path = self.__add_region("Mithalas City top path",
AquariaLocations.locations_mithalas_city_top_path) AquariaLocations.locations_mithalas_city_top_path)
self.cathedral_l = self.__add_region("Mithalas castle", AquariaLocations.locations_cathedral_l) self.cathedral_l = self.__add_region("Mithalas castle", AquariaLocations.locations_cathedral_l)
self.cathedral_l_tube = self.__add_region("Mithalas castle, plant tube entrance", self.cathedral_l_tube = self.__add_region("Mithalas castle, plant tube entrance",
AquariaLocations.locations_cathedral_l_tube) AquariaLocations.locations_cathedral_l_tube)
self.cathedral_l_sc = self.__add_region("Mithalas castle spirit cristal", self.cathedral_l_sc = self.__add_region("Mithalas castle spirit crystal",
AquariaLocations.locations_cathedral_l_sc) AquariaLocations.locations_cathedral_l_sc)
self.cathedral_r = self.__add_region("Mithalas Cathedral", self.cathedral_r = self.__add_region("Mithalas Cathedral",
AquariaLocations.locations_cathedral_r) AquariaLocations.locations_cathedral_r)
self.cathedral_underground = self.__add_region("Mithalas Cathedral underground area", self.cathedral_underground = self.__add_region("Mithalas Cathedral Underground area",
AquariaLocations.locations_cathedral_underground) AquariaLocations.locations_cathedral_underground)
self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room", self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room",
AquariaLocations.locations_cathedral_boss) AquariaLocations.locations_cathedral_boss)
@@ -310,73 +310,73 @@ class AquariaRegions:
""" """
Create the `forest_*` dans `mermog_cave` regions Create the `forest_*` dans `mermog_cave` regions
""" """
self.forest_tl = self.__add_region("Kelp forest top left area", self.forest_tl = self.__add_region("Kelp Forest top left area",
AquariaLocations.locations_forest_tl) AquariaLocations.locations_forest_tl)
self.forest_tl_fp = self.__add_region("Kelp forest top left area fish pass", self.forest_tl_fp = self.__add_region("Kelp Forest top left area fish pass",
AquariaLocations.locations_forest_tl_fp) AquariaLocations.locations_forest_tl_fp)
self.forest_tr = self.__add_region("Kelp forest top right area", self.forest_tr = self.__add_region("Kelp Forest top right area",
AquariaLocations.locations_forest_tr) AquariaLocations.locations_forest_tr)
self.forest_tr_fp = self.__add_region("Kelp forest top right area fish pass", self.forest_tr_fp = self.__add_region("Kelp Forest top right area fish pass",
AquariaLocations.locations_forest_tr_fp) AquariaLocations.locations_forest_tr_fp)
self.forest_bl = self.__add_region("Kelp forest bottom left area", self.forest_bl = self.__add_region("Kelp Forest bottom left area",
AquariaLocations.locations_forest_bl) AquariaLocations.locations_forest_bl)
self.forest_br = self.__add_region("Kelp forest bottom right area", self.forest_br = self.__add_region("Kelp Forest bottom right area",
AquariaLocations.locations_forest_br) AquariaLocations.locations_forest_br)
self.forest_sprite_cave = self.__add_region("Kelp forest spirit cave", self.forest_sprite_cave = self.__add_region("Kelp Forest spirit cave",
AquariaLocations.locations_forest_sprite_cave) AquariaLocations.locations_forest_sprite_cave)
self.forest_sprite_cave_tube = self.__add_region("Kelp forest spirit cave after the plant tube", self.forest_sprite_cave_tube = self.__add_region("Kelp Forest spirit cave after the plant tube",
AquariaLocations.locations_forest_sprite_cave_tube) AquariaLocations.locations_forest_sprite_cave_tube)
self.forest_boss = self.__add_region("Kelp forest Drunian God room", self.forest_boss = self.__add_region("Kelp Forest Drunian God room",
AquariaLocations.locations_forest_boss) AquariaLocations.locations_forest_boss)
self.forest_boss_entrance = self.__add_region("Kelp forest Drunian God room entrance", self.forest_boss_entrance = self.__add_region("Kelp Forest Drunian God room entrance",
AquariaLocations.locations_forest_boss_entrance) AquariaLocations.locations_forest_boss_entrance)
self.mermog_cave = self.__add_region("Kelp forest Mermog cave", self.mermog_cave = self.__add_region("Kelp Forest Mermog cave",
AquariaLocations.locations_mermog_cave) AquariaLocations.locations_mermog_cave)
self.mermog_boss = self.__add_region("Kelp forest Mermog cave boss", self.mermog_boss = self.__add_region("Kelp Forest Mermog cave boss",
AquariaLocations.locations_mermog_boss) AquariaLocations.locations_mermog_boss)
self.forest_fish_cave = self.__add_region("Kelp forest fish cave", self.forest_fish_cave = self.__add_region("Kelp Forest fish cave",
AquariaLocations.locations_forest_fish_cave) AquariaLocations.locations_forest_fish_cave)
self.simon = self.__add_region("Kelp forest, Simon's room", AquariaLocations.locations_simon) self.simon = self.__add_region("Kelp Forest, Simon's room", AquariaLocations.locations_simon)
def __create_veil(self) -> None: def __create_veil(self) -> None:
""" """
Create the `veil_*`, `octo_cave` and `turtle_cave` regions Create the `veil_*`, `octo_cave` and `turtle_cave` regions
""" """
self.veil_tl = self.__add_region("The veil top left area", AquariaLocations.locations_veil_tl) self.veil_tl = self.__add_region("The Veil top left area", AquariaLocations.locations_veil_tl)
self.veil_tl_fp = self.__add_region("The veil top left area fish pass", self.veil_tl_fp = self.__add_region("The Veil top left area fish pass",
AquariaLocations.locations_veil_tl_fp) AquariaLocations.locations_veil_tl_fp)
self.turtle_cave = self.__add_region("The veil top left area, turtle cave", self.turtle_cave = self.__add_region("The Veil top left area, turtle cave",
AquariaLocations.locations_turtle_cave) AquariaLocations.locations_turtle_cave)
self.turtle_cave_bubble = self.__add_region("The veil top left area, turtle cave bubble cliff", self.turtle_cave_bubble = self.__add_region("The Veil top left area, turtle cave Bubble Cliff",
AquariaLocations.locations_turtle_cave_bubble) AquariaLocations.locations_turtle_cave_bubble)
self.veil_tr_l = self.__add_region("The veil top right area, left of temple", self.veil_tr_l = self.__add_region("The Veil top right area, left of temple",
AquariaLocations.locations_veil_tr_l) AquariaLocations.locations_veil_tr_l)
self.veil_tr_r = self.__add_region("The veil top right area, right of temple", self.veil_tr_r = self.__add_region("The Veil top right area, right of temple",
AquariaLocations.locations_veil_tr_r) AquariaLocations.locations_veil_tr_r)
self.octo_cave_t = self.__add_region("Octopus cave top entrance", self.octo_cave_t = self.__add_region("Octopus Cave top entrance",
AquariaLocations.locations_octo_cave_t) AquariaLocations.locations_octo_cave_t)
self.octo_cave_b = self.__add_region("Octopus cave bottom entrance", self.octo_cave_b = self.__add_region("Octopus Cave bottom entrance",
AquariaLocations.locations_octo_cave_b) AquariaLocations.locations_octo_cave_b)
self.veil_bl = self.__add_region("The veil bottom left area", self.veil_bl = self.__add_region("The Veil bottom left area",
AquariaLocations.locations_veil_bl) AquariaLocations.locations_veil_bl)
self.veil_b_sc = self.__add_region("The veil bottom spirit cristal area", self.veil_b_sc = self.__add_region("The Veil bottom spirit crystal area",
AquariaLocations.locations_veil_b_sc) AquariaLocations.locations_veil_b_sc)
self.veil_bl_fp = self.__add_region("The veil bottom left area, in the sunken ship", self.veil_bl_fp = self.__add_region("The Veil bottom left area, in the sunken ship",
AquariaLocations.locations_veil_bl_fp) AquariaLocations.locations_veil_bl_fp)
self.veil_br = self.__add_region("The veil bottom right area", self.veil_br = self.__add_region("The Veil bottom right area",
AquariaLocations.locations_veil_br) AquariaLocations.locations_veil_br)
def __create_sun_temple(self) -> None: def __create_sun_temple(self) -> None:
""" """
Create the `sun_temple*` regions Create the `sun_temple*` regions
""" """
self.sun_temple_l = self.__add_region("Sun temple left area", self.sun_temple_l = self.__add_region("Sun Temple left area",
AquariaLocations.locations_sun_temple_l) AquariaLocations.locations_sun_temple_l)
self.sun_temple_r = self.__add_region("Sun temple right area", self.sun_temple_r = self.__add_region("Sun Temple right area",
AquariaLocations.locations_sun_temple_r) AquariaLocations.locations_sun_temple_r)
self.sun_temple_boss_path = self.__add_region("Sun temple before boss area", self.sun_temple_boss_path = self.__add_region("Sun Temple before boss area",
AquariaLocations.locations_sun_temple_boss_path) AquariaLocations.locations_sun_temple_boss_path)
self.sun_temple_boss = self.__add_region("Sun temple boss area", self.sun_temple_boss = self.__add_region("Sun Temple boss area",
AquariaLocations.locations_sun_temple_boss) AquariaLocations.locations_sun_temple_boss)
def __create_abyss(self) -> None: def __create_abyss(self) -> None:
@@ -388,9 +388,9 @@ class AquariaRegions:
AquariaLocations.locations_abyss_l) AquariaLocations.locations_abyss_l)
self.abyss_lb = self.__add_region("Abyss left bottom area", AquariaLocations.locations_abyss_lb) self.abyss_lb = self.__add_region("Abyss left bottom area", AquariaLocations.locations_abyss_lb)
self.abyss_r = self.__add_region("Abyss right area", AquariaLocations.locations_abyss_r) self.abyss_r = self.__add_region("Abyss right area", AquariaLocations.locations_abyss_r)
self.ice_cave = self.__add_region("Ice cave", AquariaLocations.locations_ice_cave) self.ice_cave = self.__add_region("Ice Cave", AquariaLocations.locations_ice_cave)
self.bubble_cave = self.__add_region("Bubble cave", AquariaLocations.locations_bubble_cave) self.bubble_cave = self.__add_region("Bubble Cave", AquariaLocations.locations_bubble_cave)
self.bubble_cave_boss = self.__add_region("Bubble cave boss area", AquariaLocations.locations_bubble_cave_boss) self.bubble_cave_boss = self.__add_region("Bubble Cave boss area", AquariaLocations.locations_bubble_cave_boss)
self.king_jellyfish_cave = self.__add_region("Abyss left area, King jellyfish cave", self.king_jellyfish_cave = self.__add_region("Abyss left area, King jellyfish cave",
AquariaLocations.locations_king_jellyfish_cave) AquariaLocations.locations_king_jellyfish_cave)
self.whale = self.__add_region("Inside the whale", AquariaLocations.locations_whale) self.whale = self.__add_region("Inside the whale", AquariaLocations.locations_whale)
@@ -400,35 +400,35 @@ class AquariaRegions:
""" """
Create the `sunken_city_*` regions Create the `sunken_city_*` regions
""" """
self.sunken_city_l = self.__add_region("Sunken city left area", self.sunken_city_l = self.__add_region("Sunken City left area",
AquariaLocations.locations_sunken_city_l) AquariaLocations.locations_sunken_city_l)
self.sunken_city_l_bedroom = self.__add_region("Sunken city left area, bedroom", self.sunken_city_l_bedroom = self.__add_region("Sunken City left area, bedroom",
AquariaLocations.locations_sunken_city_l_bedroom) AquariaLocations.locations_sunken_city_l_bedroom)
self.sunken_city_r = self.__add_region("Sunken city right area", self.sunken_city_r = self.__add_region("Sunken City right area",
AquariaLocations.locations_sunken_city_r) AquariaLocations.locations_sunken_city_r)
self.sunken_city_boss = self.__add_region("Sunken city boss area", self.sunken_city_boss = self.__add_region("Sunken City boss area",
AquariaLocations.locations_sunken_city_boss) AquariaLocations.locations_sunken_city_boss)
def __create_body(self) -> None: def __create_body(self) -> None:
""" """
Create the `body_*` and `final_boss* regions Create the `body_*` and `final_boss* regions
""" """
self.body_c = self.__add_region("The body center area", self.body_c = self.__add_region("The Body center area",
AquariaLocations.locations_body_c) AquariaLocations.locations_body_c)
self.body_l = self.__add_region("The body left area", self.body_l = self.__add_region("The Body left area",
AquariaLocations.locations_body_l) AquariaLocations.locations_body_l)
self.body_rt = self.__add_region("The body right area, top path", self.body_rt = self.__add_region("The Body right area, top path",
AquariaLocations.locations_body_rt) AquariaLocations.locations_body_rt)
self.body_rb = self.__add_region("The body right area, bottom path", self.body_rb = self.__add_region("The Body right area, bottom path",
AquariaLocations.locations_body_rb) AquariaLocations.locations_body_rb)
self.body_b = self.__add_region("The body bottom area", self.body_b = self.__add_region("The Body bottom area",
AquariaLocations.locations_body_b) AquariaLocations.locations_body_b)
self.final_boss_loby = self.__add_region("The body, before final boss", None) self.final_boss_loby = self.__add_region("The Body, before final boss", None)
self.final_boss_tube = self.__add_region("The body, final boss area turtle room", self.final_boss_tube = self.__add_region("The Body, final boss area turtle room",
AquariaLocations.locations_final_boss_tube) AquariaLocations.locations_final_boss_tube)
self.final_boss = self.__add_region("The body, final boss", self.final_boss = self.__add_region("The Body, final boss",
AquariaLocations.locations_final_boss) AquariaLocations.locations_final_boss)
self.final_boss_end = self.__add_region("The body, final boss area", None) self.final_boss_end = self.__add_region("The Body, final boss area", None)
def __connect_one_way_regions(self, source_name: str, destination_name: str, def __connect_one_way_regions(self, source_name: str, destination_name: str,
source_region: Region, source_region: Region,
@@ -455,99 +455,99 @@ class AquariaRegions:
""" """
Connect entrances of the different regions around `home_water` Connect entrances of the different regions around `home_water`
""" """
self.__connect_regions("Menu", "Verse cave right area", self.__connect_regions("Menu", "Verse Cave right area",
self.menu, self.verse_cave_r) self.menu, self.verse_cave_r)
self.__connect_regions("Verse cave left area", "Verse cave right area", self.__connect_regions("Verse Cave left area", "Verse Cave right area",
self.verse_cave_l, self.verse_cave_r) self.verse_cave_l, self.verse_cave_r)
self.__connect_regions("Verse cave", "Home water", self.verse_cave_l, self.home_water) self.__connect_regions("Verse Cave", "Home Water", self.verse_cave_l, self.home_water)
self.__connect_regions("Home Water", "Haija's home", self.home_water, self.naija_home) self.__connect_regions("Home Water", "Haija's home", self.home_water, self.naija_home)
self.__connect_regions("Home Water", "Song cave", self.home_water, self.song_cave) self.__connect_regions("Home Water", "Song Cave", self.home_water, self.song_cave)
self.__connect_regions("Home Water", "Home water, nautilus nest", self.__connect_regions("Home Water", "Home Water, nautilus nest",
self.home_water, self.home_water_nautilus, self.home_water, self.home_water_nautilus,
lambda state: _has_energy_form(state, self.player) and _has_bind_song(state, self.player)) lambda state: _has_energy_form(state, self.player) and _has_bind_song(state, self.player))
self.__connect_regions("Home Water", "Home water transturtle room", self.__connect_regions("Home Water", "Home Water transturtle room",
self.home_water, self.home_water_transturtle) self.home_water, self.home_water_transturtle)
self.__connect_regions("Home Water", "Energy temple first area", self.__connect_regions("Home Water", "Energy Temple first area",
self.home_water, self.energy_temple_1, self.home_water, self.energy_temple_1,
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
self.__connect_regions("Home Water", "Energy temple_altar", self.__connect_regions("Home Water", "Energy Temple_altar",
self.home_water, self.energy_temple_altar, self.home_water, self.energy_temple_altar,
lambda state: _has_energy_form(state, self.player) and lambda state: _has_energy_form(state, self.player) and
_has_bind_song(state, self.player)) _has_bind_song(state, self.player))
self.__connect_regions("Energy temple first area", "Energy temple second area", self.__connect_regions("Energy Temple first area", "Energy Temple second area",
self.energy_temple_1, self.energy_temple_2, self.energy_temple_1, self.energy_temple_2,
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
self.__connect_regions("Energy temple first area", "Energy temple idol room", self.__connect_regions("Energy Temple first area", "Energy Temple idol room",
self.energy_temple_1, self.energy_temple_idol, self.energy_temple_1, self.energy_temple_idol,
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
self.__connect_regions("Energy temple idol room", "Energy temple boss area", self.__connect_regions("Energy Temple idol room", "Energy Temple boss area",
self.energy_temple_idol, self.energy_temple_boss, self.energy_temple_idol, self.energy_temple_boss,
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
self.__connect_one_way_regions("Energy temple first area", "Energy temple boss area", self.__connect_one_way_regions("Energy Temple first area", "Energy Temple boss area",
self.energy_temple_1, self.energy_temple_boss, self.energy_temple_1, self.energy_temple_boss,
lambda state: _has_beast_form(state, self.player) and lambda state: _has_beast_form(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_one_way_regions("Energy temple boss area", "Energy temple first area", self.__connect_one_way_regions("Energy Temple boss area", "Energy Temple first area",
self.energy_temple_boss, self.energy_temple_1, self.energy_temple_boss, self.energy_temple_1,
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
self.__connect_regions("Energy temple second area", "Energy temple third area", self.__connect_regions("Energy Temple second area", "Energy Temple third area",
self.energy_temple_2, self.energy_temple_3, self.energy_temple_2, self.energy_temple_3,
lambda state: _has_bind_song(state, self.player) and lambda state: _has_bind_song(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_regions("Energy temple boss area", "Energy temple blaster room", self.__connect_regions("Energy Temple boss area", "Energy Temple blaster room",
self.energy_temple_boss, self.energy_temple_blaster_room, self.energy_temple_boss, self.energy_temple_blaster_room,
lambda state: _has_nature_form(state, self.player) and lambda state: _has_nature_form(state, self.player) and
_has_bind_song(state, self.player) and _has_bind_song(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_regions("Energy temple first area", "Energy temple blaster room", self.__connect_regions("Energy Temple first area", "Energy Temple blaster room",
self.energy_temple_1, self.energy_temple_blaster_room, self.energy_temple_1, self.energy_temple_blaster_room,
lambda state: _has_nature_form(state, self.player) and lambda state: _has_nature_form(state, self.player) and
_has_bind_song(state, self.player) and _has_bind_song(state, self.player) and
_has_energy_form(state, self.player) and _has_energy_form(state, self.player) and
_has_beast_form(state, self.player)) _has_beast_form(state, self.player))
self.__connect_regions("Home Water", "Open water top left area", self.__connect_regions("Home Water", "Open Water top left area",
self.home_water, self.openwater_tl) self.home_water, self.openwater_tl)
def __connect_open_water_regions(self) -> None: def __connect_open_water_regions(self) -> None:
""" """
Connect entrances of the different regions around open water Connect entrances of the different regions around open water
""" """
self.__connect_regions("Open water top left area", "Open water top right area", self.__connect_regions("Open Water top left area", "Open Water top right area",
self.openwater_tl, self.openwater_tr) self.openwater_tl, self.openwater_tr)
self.__connect_regions("Open water top left area", "Open water bottom left area", self.__connect_regions("Open Water top left area", "Open Water bottom left area",
self.openwater_tl, self.openwater_bl) self.openwater_tl, self.openwater_bl)
self.__connect_regions("Open water top left area", "forest bottom right area", self.__connect_regions("Open Water top left area", "forest bottom right area",
self.openwater_tl, self.forest_br) self.openwater_tl, self.forest_br)
self.__connect_regions("Open water top right area", "Open water top right area, turtle room", self.__connect_regions("Open Water top right area", "Open Water top right area, turtle room",
self.openwater_tr, self.openwater_tr_turtle, self.openwater_tr, self.openwater_tr_turtle,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_regions("Open water top right area", "Open water bottom right area", self.__connect_regions("Open Water top right area", "Open Water bottom right area",
self.openwater_tr, self.openwater_br) self.openwater_tr, self.openwater_br)
self.__connect_regions("Open water top right area", "Mithalas city", self.__connect_regions("Open Water top right area", "Mithalas City",
self.openwater_tr, self.mithalas_city) self.openwater_tr, self.mithalas_city)
self.__connect_regions("Open water top right area", "Veil bottom left area", self.__connect_regions("Open Water top right area", "Veil bottom left area",
self.openwater_tr, self.veil_bl) self.openwater_tr, self.veil_bl)
self.__connect_one_way_regions("Open water top right area", "Veil bottom right", self.__connect_one_way_regions("Open Water top right area", "Veil bottom right",
self.openwater_tr, self.veil_br, self.openwater_tr, self.veil_br,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_one_way_regions("Veil bottom right", "Open water top right area", self.__connect_one_way_regions("Veil bottom right", "Open Water top right area",
self.veil_br, self.openwater_tr, self.veil_br, self.openwater_tr,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_regions("Open water bottom left area", "Open water bottom right area", self.__connect_regions("Open Water bottom left area", "Open Water bottom right area",
self.openwater_bl, self.openwater_br) self.openwater_bl, self.openwater_br)
self.__connect_regions("Open water bottom left area", "Skeleton path", self.__connect_regions("Open Water bottom left area", "Skeleton path",
self.openwater_bl, self.skeleton_path) self.openwater_bl, self.skeleton_path)
self.__connect_regions("Abyss left area", "Open water bottom left area", self.__connect_regions("Abyss left area", "Open Water bottom left area",
self.abyss_l, self.openwater_bl) self.abyss_l, self.openwater_bl)
self.__connect_regions("Skeleton path", "skeleton_path_sc", self.__connect_regions("Skeleton path", "skeleton_path_sc",
self.skeleton_path, self.skeleton_path_sc, self.skeleton_path, self.skeleton_path_sc,
lambda state: _has_spirit_form(state, self.player)) lambda state: _has_spirit_form(state, self.player))
self.__connect_regions("Abyss right area", "Open water bottom right area", self.__connect_regions("Abyss right area", "Open Water bottom right area",
self.abyss_r, self.openwater_br) self.abyss_r, self.openwater_br)
self.__connect_one_way_regions("Open water bottom right area", "Arnassi", self.__connect_one_way_regions("Open Water bottom right area", "Arnassi",
self.openwater_br, self.arnassi, self.openwater_br, self.arnassi,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_one_way_regions("Arnassi", "Open water bottom right area", self.__connect_one_way_regions("Arnassi", "Open Water bottom right area",
self.arnassi, self.openwater_br) self.arnassi, self.openwater_br)
self.__connect_regions("Arnassi", "Arnassi path", self.__connect_regions("Arnassi", "Arnassi path",
self.arnassi, self.arnassi_path) self.arnassi, self.arnassi_path)
@@ -562,23 +562,23 @@ class AquariaRegions:
""" """
Connect entrances of the different regions around Mithalas Connect entrances of the different regions around Mithalas
""" """
self.__connect_one_way_regions("Mithalas city", "Mithalas city top path", self.__connect_one_way_regions("Mithalas City", "Mithalas City top path",
self.mithalas_city, self.mithalas_city_top_path, self.mithalas_city, self.mithalas_city_top_path,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_one_way_regions("Mithalas city_top_path", "Mithalas city", self.__connect_one_way_regions("Mithalas City_top_path", "Mithalas City",
self.mithalas_city_top_path, self.mithalas_city) self.mithalas_city_top_path, self.mithalas_city)
self.__connect_regions("Mithalas city", "Mithalas city home with fishpass", self.__connect_regions("Mithalas City", "Mithalas City home with fishpass",
self.mithalas_city, self.mithalas_city_fishpass, self.mithalas_city, self.mithalas_city_fishpass,
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
self.__connect_regions("Mithalas city", "Mithalas castle", self.__connect_regions("Mithalas City", "Mithalas castle",
self.mithalas_city, self.cathedral_l, self.mithalas_city, self.cathedral_l,
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
self.__connect_one_way_regions("Mithalas city top path", "Mithalas castle, flower tube", self.__connect_one_way_regions("Mithalas City top path", "Mithalas castle, flower tube",
self.mithalas_city_top_path, self.mithalas_city_top_path,
self.cathedral_l_tube, self.cathedral_l_tube,
lambda state: _has_nature_form(state, self.player) and lambda state: _has_nature_form(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_one_way_regions("Mithalas castle, flower tube area", "Mithalas city top path", self.__connect_one_way_regions("Mithalas castle, flower tube area", "Mithalas City top path",
self.cathedral_l_tube, self.cathedral_l_tube,
self.mithalas_city_top_path, self.mithalas_city_top_path,
lambda state: _has_beast_form(state, self.player) and lambda state: _has_beast_form(state, self.player) and
@@ -690,22 +690,22 @@ class AquariaRegions:
self.veil_tl, self.veil_tr_r) self.veil_tl, self.veil_tr_r)
self.__connect_regions("Veil top left area", "Turtle cave", self.__connect_regions("Veil top left area", "Turtle cave",
self.veil_tl, self.turtle_cave) self.veil_tl, self.turtle_cave)
self.__connect_regions("Turtle cave", "Turtle cave bubble cliff", self.__connect_regions("Turtle cave", "Turtle cave Bubble Cliff",
self.turtle_cave, self.turtle_cave_bubble, self.turtle_cave, self.turtle_cave_bubble,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_regions("Veil right of sun temple", "Sun temple right area", self.__connect_regions("Veil right of sun temple", "Sun Temple right area",
self.veil_tr_r, self.sun_temple_r) self.veil_tr_r, self.sun_temple_r)
self.__connect_regions("Sun temple right area", "Sun temple left area", self.__connect_regions("Sun Temple right area", "Sun Temple left area",
self.sun_temple_r, self.sun_temple_l, self.sun_temple_r, self.sun_temple_l,
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
self.__connect_regions("Sun temple left area", "Veil left of sun temple", self.__connect_regions("Sun Temple left area", "Veil left of sun temple",
self.sun_temple_l, self.veil_tr_l) self.sun_temple_l, self.veil_tr_l)
self.__connect_regions("Sun temple left area", "Sun temple before boss area", self.__connect_regions("Sun Temple left area", "Sun Temple before boss area",
self.sun_temple_l, self.sun_temple_boss_path) self.sun_temple_l, self.sun_temple_boss_path)
self.__connect_regions("Sun temple before boss area", "Sun temple boss area", self.__connect_regions("Sun Temple before boss area", "Sun Temple boss area",
self.sun_temple_boss_path, self.sun_temple_boss, self.sun_temple_boss_path, self.sun_temple_boss,
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
self.__connect_one_way_regions("Sun temple boss area", "Veil left of sun temple", self.__connect_one_way_regions("Sun Temple boss area", "Veil left of sun temple",
self.sun_temple_boss, self.veil_tr_l) self.sun_temple_boss, self.veil_tr_l)
self.__connect_regions("Veil left of sun temple", "Octo cave top path", self.__connect_regions("Veil left of sun temple", "Octo cave top path",
self.veil_tr_l, self.octo_cave_t, self.veil_tr_l, self.octo_cave_t,
@@ -724,7 +724,7 @@ class AquariaRegions:
self.__connect_regions("Abyss left area", "Abyss bottom of left area", self.__connect_regions("Abyss left area", "Abyss bottom of left area",
self.abyss_l, self.abyss_lb, self.abyss_l, self.abyss_lb,
lambda state: _has_nature_form(state, self.player)) lambda state: _has_nature_form(state, self.player))
self.__connect_regions("Abyss left bottom area", "Sunken city right area", self.__connect_regions("Abyss left bottom area", "Sunken City right area",
self.abyss_lb, self.sunken_city_r, self.abyss_lb, self.sunken_city_r,
lambda state: _has_li(state, self.player)) lambda state: _has_li(state, self.player))
self.__connect_one_way_regions("Abyss left bottom area", "Body center area", self.__connect_one_way_regions("Abyss left bottom area", "Body center area",
@@ -748,13 +748,13 @@ class AquariaRegions:
_has_sun_form(state, self.player) and _has_sun_form(state, self.player) and
_has_bind_song(state, self.player) and _has_bind_song(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_regions("Abyss right area", "Ice cave", self.__connect_regions("Abyss right area", "Ice Cave",
self.abyss_r, self.ice_cave, self.abyss_r, self.ice_cave,
lambda state: _has_spirit_form(state, self.player)) lambda state: _has_spirit_form(state, self.player))
self.__connect_regions("Abyss right area", "Bubble cave", self.__connect_regions("Abyss right area", "Bubble Cave",
self.ice_cave, self.bubble_cave, self.ice_cave, self.bubble_cave,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_regions("Bubble cave boss area", "Bubble cave", self.__connect_regions("Bubble Cave boss area", "Bubble Cave",
self.bubble_cave, self.bubble_cave_boss, self.bubble_cave, self.bubble_cave_boss,
lambda state: _has_nature_form(state, self.player) and _has_bind_song(state, self.player) lambda state: _has_nature_form(state, self.player) and _has_bind_song(state, self.player)
) )
@@ -763,20 +763,21 @@ class AquariaRegions:
""" """
Connect entrances of the different regions around The Sunken City Connect entrances of the different regions around The Sunken City
""" """
self.__connect_regions("Sunken city right area", "Sunken city left area", self.__connect_regions("Sunken City right area", "Sunken City left area",
self.sunken_city_r, self.sunken_city_l) self.sunken_city_r, self.sunken_city_l)
self.__connect_regions("Sunken city left area", "Sunken city bedroom", self.__connect_regions("Sunken City left area", "Sunken City bedroom",
self.sunken_city_l, self.sunken_city_l_bedroom, self.sunken_city_l, self.sunken_city_l_bedroom,
lambda state: _has_spirit_form(state, self.player)) lambda state: _has_spirit_form(state, self.player))
self.__connect_regions("Sunken city left area", "Sunken city boss area", self.__connect_regions("Sunken City left area", "Sunken City boss area",
self.sunken_city_l, self.sunken_city_boss, self.sunken_city_l, self.sunken_city_boss,
lambda state: _has_beast_form(state, self.player) and lambda state: _has_beast_form(state, self.player) and
_has_sun_form(state, self.player) and
_has_energy_form(state, self.player) and _has_energy_form(state, self.player) and
_has_bind_song(state, self.player)) _has_bind_song(state, self.player))
def __connect_body_regions(self) -> None: def __connect_body_regions(self) -> None:
""" """
Connect entrances of the different regions around The body Connect entrances of the different regions around The Body
""" """
self.__connect_regions("Body center area", "Body left area", self.__connect_regions("Body center area", "Body left area",
self.body_c, self.body_l) self.body_c, self.body_l)
@@ -787,13 +788,13 @@ class AquariaRegions:
self.__connect_regions("Body center area", "Body bottom area", self.__connect_regions("Body center area", "Body bottom area",
self.body_c, self.body_b, self.body_c, self.body_b,
lambda state: _has_dual_form(state, self.player)) lambda state: _has_dual_form(state, self.player))
self.__connect_regions("Body bottom area", "Final boss area", self.__connect_regions("Body bottom area", "Final Boss area",
self.body_b, self.final_boss_loby, self.body_b, self.final_boss_loby,
lambda state: _has_dual_form(state, self.player)) lambda state: _has_dual_form(state, self.player))
self.__connect_regions("Before Final boss", "Final boss tube", self.__connect_regions("Before Final Boss", "Final Boss tube",
self.final_boss_loby, self.final_boss_tube, self.final_boss_loby, self.final_boss_tube,
lambda state: _has_nature_form(state, self.player)) lambda state: _has_nature_form(state, self.player))
self.__connect_one_way_regions("Before Final boss", "Final boss", self.__connect_one_way_regions("Before Final Boss", "Final Boss",
self.final_boss_loby, self.final_boss, self.final_boss_loby, self.final_boss,
lambda state: _has_energy_form(state, self.player) and lambda state: _has_energy_form(state, self.player) and
_has_dual_form(state, self.player) and _has_dual_form(state, self.player) and
@@ -814,7 +815,7 @@ class AquariaRegions:
def __connect_arnassi_path_transturtle(self, item_source: str, item_target: str, region_source: Region, def __connect_arnassi_path_transturtle(self, item_source: str, item_target: str, region_source: Region,
region_target: Region) -> None: region_target: Region) -> None:
"""Connect the Arnassi ruins transturtle to another one""" """Connect the Arnassi Ruins transturtle to another one"""
self.__connect_one_way_regions(item_source, item_target, region_source, region_target, self.__connect_one_way_regions(item_source, item_target, region_source, region_target,
lambda state: state.has(item_target, self.player) and lambda state: state.has(item_target, self.player) and
_has_fish_form(state, self.player)) _has_fish_form(state, self.player))
@@ -825,25 +826,25 @@ class AquariaRegions:
self.__connect_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l) self.__connect_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l)
self.__connect_transturtle(item, "Transturtle Open Water top right", region, self.openwater_tr_turtle) self.__connect_transturtle(item, "Transturtle Open Water top right", region, self.openwater_tr_turtle)
self.__connect_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl) self.__connect_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl)
self.__connect_transturtle(item, "Transturtle Home water", region, self.home_water_transturtle) self.__connect_transturtle(item, "Transturtle Home Water", region, self.home_water_transturtle)
self.__connect_transturtle(item, "Transturtle Abyss right", region, self.abyss_r) self.__connect_transturtle(item, "Transturtle Abyss right", region, self.abyss_r)
self.__connect_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube) self.__connect_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube)
self.__connect_transturtle(item, "Transturtle Simon says", region, self.simon) self.__connect_transturtle(item, "Transturtle Simon Says", region, self.simon)
self.__connect_transturtle(item, "Transturtle Arnassi ruins", region, self.arnassi_path, self.__connect_transturtle(item, "Transturtle Arnassi Ruins", region, self.arnassi_path,
lambda state: state.has("Transturtle Arnassi ruins", self.player) and lambda state: state.has("Transturtle Arnassi Ruins", self.player) and
_has_fish_form(state, self.player)) _has_fish_form(state, self.player))
def _connect_arnassi_path_transturtle_to_other(self, item: str, region: Region) -> None: def _connect_arnassi_path_transturtle_to_other(self, item: str, region: Region) -> None:
"""Connect the Arnassi ruins transturtle to all others""" """Connect the Arnassi Ruins transturtle to all others"""
self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top left", region, self.veil_tl) self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top left", region, self.veil_tl)
self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l) self.__connect_arnassi_path_transturtle(item, "Transturtle Veil top right", region, self.veil_tr_l)
self.__connect_arnassi_path_transturtle(item, "Transturtle Open Water top right", region, self.__connect_arnassi_path_transturtle(item, "Transturtle Open Water top right", region,
self.openwater_tr_turtle) self.openwater_tr_turtle)
self.__connect_arnassi_path_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl) self.__connect_arnassi_path_transturtle(item, "Transturtle Forest bottom left", region, self.forest_bl)
self.__connect_arnassi_path_transturtle(item, "Transturtle Home water", region, self.home_water_transturtle) self.__connect_arnassi_path_transturtle(item, "Transturtle Home Water", region, self.home_water_transturtle)
self.__connect_arnassi_path_transturtle(item, "Transturtle Abyss right", region, self.abyss_r) self.__connect_arnassi_path_transturtle(item, "Transturtle Abyss right", region, self.abyss_r)
self.__connect_arnassi_path_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube) self.__connect_arnassi_path_transturtle(item, "Transturtle Final Boss", region, self.final_boss_tube)
self.__connect_arnassi_path_transturtle(item, "Transturtle Simon says", region, self.simon) self.__connect_arnassi_path_transturtle(item, "Transturtle Simon Says", region, self.simon)
def __connect_transturtles(self) -> None: def __connect_transturtles(self) -> None:
"""Connect every transturtle with others""" """Connect every transturtle with others"""
@@ -851,11 +852,11 @@ class AquariaRegions:
self._connect_transturtle_to_other("Transturtle Veil top right", self.veil_tr_l) self._connect_transturtle_to_other("Transturtle Veil top right", self.veil_tr_l)
self._connect_transturtle_to_other("Transturtle Open Water top right", self.openwater_tr_turtle) self._connect_transturtle_to_other("Transturtle Open Water top right", self.openwater_tr_turtle)
self._connect_transturtle_to_other("Transturtle Forest bottom left", self.forest_bl) self._connect_transturtle_to_other("Transturtle Forest bottom left", self.forest_bl)
self._connect_transturtle_to_other("Transturtle Home water", self.home_water_transturtle) self._connect_transturtle_to_other("Transturtle Home Water", self.home_water_transturtle)
self._connect_transturtle_to_other("Transturtle Abyss right", self.abyss_r) self._connect_transturtle_to_other("Transturtle Abyss right", self.abyss_r)
self._connect_transturtle_to_other("Transturtle Final Boss", self.final_boss_tube) self._connect_transturtle_to_other("Transturtle Final Boss", self.final_boss_tube)
self._connect_transturtle_to_other("Transturtle Simon says", self.simon) self._connect_transturtle_to_other("Transturtle Simon Says", self.simon)
self._connect_arnassi_path_transturtle_to_other("Transturtle Arnassi ruins", self.arnassi_path) self._connect_arnassi_path_transturtle_to_other("Transturtle Arnassi Ruins", self.arnassi_path)
def connect_regions(self) -> None: def connect_regions(self) -> None:
""" """
@@ -907,7 +908,7 @@ class AquariaRegions:
def __add_event_mini_bosses(self) -> None: def __add_event_mini_bosses(self) -> None:
""" """
Add every mini bosses (excluding Energy statue and Simon says) Add every mini bosses (excluding Energy Statue and Simon Says)
events to the `world` events to the `world`
""" """
self.__add_event_location(self.home_water_nautilus, self.__add_event_location(self.home_water_nautilus,
@@ -967,100 +968,100 @@ class AquariaRegions:
def __adjusting_urns_rules(self) -> None: def __adjusting_urns_rules(self) -> None:
"""Since Urns need to be broken, add a damaging item to rules""" """Since Urns need to be broken, add a damaging item to rules"""
add_rule(self.multiworld.get_location("Open water top right area, first urn in the Mithalas exit", self.player), add_rule(self.multiworld.get_location("Open Water top right area, first urn in the Mithalas exit", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Open water top right area, second urn in the Mithalas exit", self.player), add_rule(self.multiworld.get_location("Open Water top right area, second urn in the Mithalas exit", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Open water top right area, third urn in the Mithalas exit", self.player), add_rule(self.multiworld.get_location("Open Water top right area, third urn in the Mithalas exit", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, first urn in one of the homes", self.player), add_rule(self.multiworld.get_location("Mithalas City, first urn in one of the homes", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, second urn in one of the homes", self.player), add_rule(self.multiworld.get_location("Mithalas City, second urn in one of the homes", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, first urn in the city reserve", self.player), add_rule(self.multiworld.get_location("Mithalas City, first urn in the city reserve", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, second urn in the city reserve", self.player), add_rule(self.multiworld.get_location("Mithalas City, second urn in the city reserve", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, third urn in the city reserve", self.player), add_rule(self.multiworld.get_location("Mithalas City, third urn in the city reserve", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, urn in the cathedral flower tube entrance", self.player), add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, urn in the bedroom", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bedroom", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, first urn of the single lamp path", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, first urn of the single lamp path", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, second urn of the single lamp path", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, second urn of the single lamp path", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, urn in the bottom room", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bottom room", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, first urn on the entrance path", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, first urn on the entrance path", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city castle, second urn on the entrance path", self.player), add_rule(self.multiworld.get_location("Mithalas City Castle, second urn on the entrance path", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas city, urn inside a home fish pass", self.player), add_rule(self.multiworld.get_location("Mithalas City, urn inside a home fish pass", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
def __adjusting_crates_rules(self) -> None: def __adjusting_crates_rules(self) -> None:
"""Since Crate need to be broken, add a damaging item to rules""" """Since Crate need to be broken, add a damaging item to rules"""
add_rule(self.multiworld.get_location("Sunken city right area, crate close to the save cristal", self.player), add_rule(self.multiworld.get_location("Sunken City right area, crate close to the save crystal", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Sunken city right area, crate in the left bottom room", self.player), add_rule(self.multiworld.get_location("Sunken City right area, crate in the left bottom room", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Sunken city left area, crate in the little pipe room", self.player), add_rule(self.multiworld.get_location("Sunken City left area, crate in the little pipe room", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Sunken city left area, crate close to the save cristal", self.player), add_rule(self.multiworld.get_location("Sunken City left area, crate close to the save crystal", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Sunken city left area, crate before the bedroom", self.player), add_rule(self.multiworld.get_location("Sunken City left area, crate before the bedroom", self.player),
lambda state: _has_damaging_item(state, self.player)) lambda state: _has_damaging_item(state, self.player))
def __adjusting_soup_rules(self) -> None: def __adjusting_soup_rules(self) -> None:
""" """
Modify rules for location that need soup Modify rules for location that need soup
""" """
add_rule(self.multiworld.get_location("Turtle cave, Urchin costume", self.player), add_rule(self.multiworld.get_location("Turtle cave, Urchin Costume", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player)) lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Sun Worm path, first cliff bulb", self.player), add_rule(self.multiworld.get_location("Sun Worm path, first cliff bulb", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player)) lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player), add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player)) lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("The veil top right area, bulb in the top of the water fall", self.player), add_rule(self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player)) lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
def __adjusting_under_rock_location(self) -> None: def __adjusting_under_rock_location(self) -> None:
""" """
Modify rules implying bind song needed for bulb under rocks Modify rules implying bind song needed for bulb under rocks
""" """
add_rule(self.multiworld.get_location("Home water, bulb under the rock in the left path from the verse cave", add_rule(self.multiworld.get_location("Home Water, bulb under the rock in the left path from the Verse Cave",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Verse cave left area, bulb under the rock at the end of the path", add_rule(self.multiworld.get_location("Verse Cave left area, bulb under the rock at the end of the path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Naija's home, bulb under the rock at the right of the main path", add_rule(self.multiworld.get_location("Naija's Home, bulb under the rock at the right of the main path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Song cave, bulb under the rock in the path to the singing statues", add_rule(self.multiworld.get_location("Song Cave, bulb under the rock in the path to the singing statues",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Song cave, bulb under the rock close to the song door", add_rule(self.multiworld.get_location("Song Cave, bulb under the rock close to the song door",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Energy temple second area, bulb under the rock", add_rule(self.multiworld.get_location("Energy Temple second area, bulb under the rock",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Open water top left area, bulb under the rock in the right path", add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the right path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Open water top left area, bulb under the rock in the left path", add_rule(self.multiworld.get_location("Open Water top left area, bulb under the rock in the left path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Kelp Forest top right area, bulb under the rock in the right path", add_rule(self.multiworld.get_location("Kelp Forest top right area, bulb under the rock in the right path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("The veil top left area, bulb under the rock in the top right path", add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Abyss right area, bulb in the middle path", add_rule(self.multiworld.get_location("Abyss right area, bulb in the middle path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("The veil top left area, bulb under the rock in the top right path", add_rule(self.multiworld.get_location("The Veil top left area, bulb under the rock in the top right path",
self.player), lambda state: _has_bind_song(state, self.player)) self.player), lambda state: _has_bind_song(state, self.player))
def __adjusting_light_in_dark_place_rules(self) -> None: def __adjusting_light_in_dark_place_rules(self) -> None:
add_rule(self.multiworld.get_location("Kelp forest top right area, Black pearl", self.player), add_rule(self.multiworld.get_location("Kelp Forest top right area, Black Pearl", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_location("Kelp forest bottom right area, Odd Container", self.player), add_rule(self.multiworld.get_location("Kelp Forest bottom right area, Odd Container", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Veil top left to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Veil top left to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
@@ -1070,103 +1071,103 @@ class AquariaRegions:
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Forest bottom left to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Forest bottom left to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Home water to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Home Water to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Final Boss to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Final Boss to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Simon says to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Simon Says to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Transturtle Arnassi ruins to Transturtle Abyss right", self.player), add_rule(self.multiworld.get_entrance("Transturtle Arnassi Ruins to Transturtle Abyss right", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Body center area to Abyss left bottom area", self.player), add_rule(self.multiworld.get_entrance("Body center area to Abyss left bottom area", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Veil left of sun temple to Octo cave top path", self.player), add_rule(self.multiworld.get_entrance("Veil left of sun temple to Octo cave top path", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Open water bottom right area to Abyss right area", self.player), add_rule(self.multiworld.get_entrance("Open Water bottom right area to Abyss right area", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Open water bottom left area to Abyss left area", self.player), add_rule(self.multiworld.get_entrance("Open Water bottom left area to Abyss left area", self.player),
lambda state: _has_light(state, self.player)) lambda state: _has_light(state, self.player))
add_rule(self.multiworld.get_entrance("Sun temple left area to Sun temple right area", self.player), add_rule(self.multiworld.get_entrance("Sun Temple left area to Sun Temple right area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player)) lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance("Sun temple right area to Sun temple left area", self.player), add_rule(self.multiworld.get_entrance("Sun Temple right area to Sun Temple left area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player)) lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance("Veil left of sun temple to Sun temple left area", self.player), add_rule(self.multiworld.get_entrance("Veil left of sun temple to Sun Temple left area", self.player),
lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player)) lambda state: _has_light(state, self.player) or _has_sun_crystal(state, self.player))
def __adjusting_manual_rules(self) -> None: def __adjusting_manual_rules(self) -> None:
add_rule(self.multiworld.get_location("Mithalas cathedral, Mithalan Dress", self.player), add_rule(self.multiworld.get_location("Mithalas Cathedral, Mithalan Dress", self.player),
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Open water bottom left area, bulb inside the lowest fish pass", self.player), add_rule(self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player),
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
add_rule(self.multiworld.get_location("Kelp forest bottom left area, Walker baby", self.player), add_rule(self.multiworld.get_location("Kelp Forest bottom left area, Walker baby", self.player),
lambda state: _has_spirit_form(state, self.player)) lambda state: _has_spirit_form(state, self.player))
add_rule(self.multiworld.get_location("The veil top left area, bulb hidden behind the blocking rock", self.player), add_rule(self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Turtle cave, Turtle Egg", self.player), add_rule(self.multiworld.get_location("Turtle cave, Turtle Egg", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Abyss left area, bulb in the bottom fish pass", self.player), add_rule(self.multiworld.get_location("Abyss left area, bulb in the bottom fish pass", self.player),
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
add_rule(self.multiworld.get_location("Song cave, Anemone seed", self.player), add_rule(self.multiworld.get_location("Song Cave, Anemone Seed", self.player),
lambda state: _has_nature_form(state, self.player)) lambda state: _has_nature_form(state, self.player))
add_rule(self.multiworld.get_location("Song cave, Verse egg", self.player), add_rule(self.multiworld.get_location("Song Cave, Verse Egg", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Verse cave right area, Big Seed", self.player), add_rule(self.multiworld.get_location("Verse Cave right area, Big Seed", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Arnassi ruins, Song plant spore on the top of the ruins", self.player), add_rule(self.multiworld.get_location("Arnassi Ruins, Song Plant Spore", self.player),
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Energy temple first area, bulb in the bottom room blocked by a rock", add_rule(self.multiworld.get_location("Energy Temple first area, bulb in the bottom room blocked by a rock",
self.player), lambda state: _has_energy_form(state, self.player)) self.player), lambda state: _has_energy_form(state, self.player))
add_rule(self.multiworld.get_location("Home water, bulb in the bottom left room", self.player), add_rule(self.multiworld.get_location("Home Water, bulb in the bottom left room", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Home water, bulb in the path below Nautilus Prime", self.player), add_rule(self.multiworld.get_location("Home Water, bulb in the path below Nautilus Prime", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location("Naija's home, bulb after the energy door", self.player), add_rule(self.multiworld.get_location("Naija's Home, bulb after the energy door", self.player),
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", self.player), add_rule(self.multiworld.get_location("Abyss right area, bulb behind the rock in the whale room", self.player),
lambda state: _has_spirit_form(state, self.player) and lambda state: _has_spirit_form(state, self.player) and
_has_sun_form(state, self.player)) _has_sun_form(state, self.player))
add_rule(self.multiworld.get_location("Arnassi ruins, Arnassi Armor", self.player), add_rule(self.multiworld.get_location("Arnassi Ruins, Arnassi Armor", self.player),
lambda state: _has_fish_form(state, self.player) and lambda state: _has_fish_form(state, self.player) and
_has_spirit_form(state, self.player)) _has_spirit_form(state, self.player))
def __no_progression_hard_or_hidden_location(self) -> None: def __no_progression_hard_or_hidden_location(self) -> None:
self.multiworld.get_location("Energy temple boss area, Fallen god tooth", self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Cathedral boss area, beating Mithalan God", self.multiworld.get_location("Cathedral boss area, beating Mithalan God",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Kelp forest boss area, beating Drunian God", self.multiworld.get_location("Kelp Forest boss area, beating Drunian God",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sun temple boss area, beating Sun God", self.multiworld.get_location("Sun Temple boss area, beating Sun God",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sunken city, bulb on the top of the boss area (boiler room)", self.multiworld.get_location("Sunken City, bulb on top of the boss area",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Home water, Nautilus Egg", self.multiworld.get_location("Home Water, Nautilus Egg",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Energy temple blaster room, Blaster egg", self.multiworld.get_location("Energy Temple blaster room, Blaster Egg",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Mithalas castle, beating the priests", self.multiworld.get_location("Mithalas City Castle, beating the Priests",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Mermog cave, Piranha Egg", self.multiworld.get_location("Mermog cave, Piranha Egg",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Octopus cave, Dumbo Egg", self.multiworld.get_location("Octopus Cave, Dumbo Egg",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("King Jellyfish cave, bulb in the right path from King Jelly", self.multiworld.get_location("King Jellyfish Cave, bulb in the right path from King Jelly",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("King Jellyfish cave, Jellyfish Costume", self.multiworld.get_location("King Jellyfish Cave, Jellyfish Costume",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Final boss area, bulb in the boss third form room", self.multiworld.get_location("Final Boss area, bulb in the boss third form room",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sun Worm path, first cliff bulb", self.multiworld.get_location("Sun Worm path, first cliff bulb",
@@ -1175,34 +1176,34 @@ class AquariaRegions:
self.multiworld.get_location("Sun Worm path, second cliff bulb", self.multiworld.get_location("Sun Worm path, second cliff bulb",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("The veil top right area, bulb in the top of the water fall", self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Bubble cave, bulb in the left cave wall", self.multiworld.get_location("Bubble Cave, bulb in the left cave wall",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Bubble cave, bulb in the right cave wall (behind the ice cristal)", self.multiworld.get_location("Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Bubble cave, Verse egg", self.multiworld.get_location("Bubble Cave, Verse Egg",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals", self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Kelp forest bottom left area, Walker baby", self.multiworld.get_location("Kelp Forest bottom left area, Walker baby",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sun temple, Sun key", self.multiworld.get_location("Sun Temple, Sun Key",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("The body bottom area, Mutant Costume", self.multiworld.get_location("The Body bottom area, Mutant Costume",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sun temple, bulb in the hidden room of the right part", self.multiworld.get_location("Sun Temple, bulb in the hidden room of the right part",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Arnassi ruins, Arnassi Armor", self.multiworld.get_location("Arnassi Ruins, Arnassi Armor",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
@@ -1220,19 +1221,19 @@ class AquariaRegions:
self.__adjusting_under_rock_location() self.__adjusting_under_rock_location()
if options.mini_bosses_to_beat.value > 0: if options.mini_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance("Before Final boss to Final boss", self.player), add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player),
lambda state: _has_mini_bosses(state, self.player)) lambda state: _has_mini_bosses(state, self.player))
if options.big_bosses_to_beat.value > 0: if options.big_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance("Before Final boss to Final boss", self.player), add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player),
lambda state: _has_big_bosses(state, self.player)) lambda state: _has_big_bosses(state, self.player))
if options.objective.value == 1: if options.objective.value == 1:
add_rule(self.multiworld.get_entrance("Before Final boss to Final boss", self.player), add_rule(self.multiworld.get_entrance("Before Final Boss to Final Boss", self.player),
lambda state: _has_secrets(state, self.player)) lambda state: _has_secrets(state, self.player))
if options.unconfine_home_water.value in [0, 1]: if options.unconfine_home_water.value in [0, 1]:
add_rule(self.multiworld.get_entrance("Home Water to Home water transturtle room", self.player), add_rule(self.multiworld.get_entrance("Home Water to Home Water transturtle room", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
if options.unconfine_home_water.value in [0, 2]: if options.unconfine_home_water.value in [0, 2]:
add_rule(self.multiworld.get_entrance("Home Water to Open water top left area", self.player), add_rule(self.multiworld.get_entrance("Home Water to Open Water top left area", self.player),
lambda state: _has_bind_song(state, self.player) and _has_energy_form(state, self.player)) lambda state: _has_bind_song(state, self.player) and _has_energy_form(state, self.player))
if options.early_energy_form: if options.early_energy_form:
self.multiworld.early_items[self.player]["Energy form"] = 1 self.multiworld.early_items[self.player]["Energy form"] = 1

View File

@@ -71,9 +71,9 @@ class AquariaWorld(World):
item_name_groups = { item_name_groups = {
"Damage": {"Energy form", "Nature form", "Beast form", "Damage": {"Energy form", "Nature form", "Beast form",
"Li and Li song", "Baby nautilus", "Baby piranha", "Li and Li song", "Baby Nautilus", "Baby Piranha",
"Baby blaster"}, "Baby Blaster"},
"Light": {"Sun form", "Baby dumbo"} "Light": {"Sun form", "Baby Dumbo"}
} }
"""Grouping item make it easier to find them""" """Grouping item make it easier to find them"""
@@ -152,29 +152,25 @@ class AquariaWorld(World):
precollected = [item.name for item in self.multiworld.precollected_items[self.player]] precollected = [item.name for item in self.multiworld.precollected_items[self.player]]
if self.options.turtle_randomizer.value > 0: if self.options.turtle_randomizer.value > 0:
if self.options.turtle_randomizer.value == 2: if self.options.turtle_randomizer.value == 2:
self.__pre_fill_item("Transturtle Final Boss", "Final boss area, Transturtle", precollected) self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected)
else: else:
self.__pre_fill_item("Transturtle Veil top left", "The veil top left area, Transturtle", precollected) self.__pre_fill_item("Transturtle Veil top left", "The Veil top left area, Transturtle", precollected)
self.__pre_fill_item("Transturtle Veil top right", "The veil top right area, Transturtle", precollected) self.__pre_fill_item("Transturtle Veil top right", "The Veil top right area, Transturtle", precollected)
self.__pre_fill_item("Transturtle Open Water top right", "Open water top right area, Transturtle", self.__pre_fill_item("Transturtle Open Water top right", "Open Water top right area, Transturtle",
precollected) precollected)
self.__pre_fill_item("Transturtle Forest bottom left", "Kelp Forest bottom left area, Transturtle", self.__pre_fill_item("Transturtle Forest bottom left", "Kelp Forest bottom left area, Transturtle",
precollected) precollected)
self.__pre_fill_item("Transturtle Home water", "Home water, Transturtle", precollected) self.__pre_fill_item("Transturtle Home Water", "Home Water, Transturtle", precollected)
self.__pre_fill_item("Transturtle Abyss right", "Abyss right area, Transturtle", precollected) self.__pre_fill_item("Transturtle Abyss right", "Abyss right area, Transturtle", precollected)
self.__pre_fill_item("Transturtle Final Boss", "Final boss area, Transturtle", precollected) self.__pre_fill_item("Transturtle Final Boss", "Final Boss area, Transturtle", precollected)
# The last two are inverted because in the original game, they are special turtle that communicate directly # The last two are inverted because in the original game, they are special turtle that communicate directly
self.__pre_fill_item("Transturtle Simon says", "Arnassi Ruins, Transturtle", precollected) self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected)
self.__pre_fill_item("Transturtle Arnassi ruins", "Simon says area, Transturtle", precollected) self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected)
for name, data in item_table.items(): for name, data in item_table.items():
if name in precollected: if name not in self.exclude:
precollected.remove(name) for i in range(data.count):
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name())) item = self.create_item(name)
else: self.multiworld.itempool.append(item)
if name not in self.exclude:
for i in range(data.count):
item = self.create_item(name)
self.multiworld.itempool.append(item)
def set_rules(self) -> None: def set_rules(self) -> None:
""" """

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