Compare commits

...

161 Commits

Author SHA1 Message Date
Ishigh1 6875f9da90 RuleBuilder: Optimize Has("Item", 0) (#6228) 2026-07-18 12:32:54 +02:00
Katelyn Gigante bbd5fc3822 Launcher: Delete unused functions (#6332)
These were moved to LauncherComponents in #6115,  but due to a bad merge resolution, the originals were left behind.
2026-07-18 11:28:22 +02:00
Berserker ae7c5609cd Launcher: fix world import ordering conflict between #6115 and #6058 2026-07-18 04:53:04 +02:00
Fabian Dill c8cf2c4112 Launcher: Loading Screen (#6115) 2026-07-18 02:38:34 +02:00
Katelyn Gigante feab54daec Core: Add parameter to visualize_regions to display Rule Builder explanations on exits (#6184)
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2026-07-17 20:08:20 +02:00
g0goTAS 13f1929286 Core: Pre Output Steps Log (#6047) 2026-07-17 20:07:07 +02:00
Patrick Wehbe c6a7c0c193 Docs: add guidance for choosing minimum/maximum_ap_version (#6265) 2026-07-17 20:06:10 +02:00
James White 4efce428a4 Core: Adjust YAML error message to clarify the name is not the cause of the error (#6267) 2026-07-17 20:03:52 +02:00
Doug Hoskisson 6907d89968 Core: fix Set import (#6235)
mistake made in pr #5048
https://github.com/ArchipelagoMW/Archipelago/pull/5048#discussion_r3328989050
2026-07-17 20:03:20 +02:00
James White 9c80dc6257 Core: Allow running unit tests scoped to specific worlds (#6290)
This allows the unit tests to be run on specific worlds only, it works by using an environment variable AP_TEST_WORLDS which is a comma separated list of world directories.
2026-07-17 20:01:28 +02:00
dependabot[bot] c311685064 WebHost: Bump mistune from 3.2.1 to 3.3.0 in /WebHostLib #6324 2026-07-16 00:04:19 +02:00
jamesbrq f110a7efbc Gauntlet Legends: Implement New Game (#3499)
* Update GauntletLegendsClient.py

* Continuation of obelisk update

* APPP Implementation Groundwork.

* Continuation of APPP Implementation

* Create Pain.asm

* ROM verification modified since crc32 hash can no longer be calculated.

* Initial upload of barrel/chest locations.

* Finished adding and naming all chests/barrels.

* Continued locations.

* Added new yaml options

* Work on mirror shards

* Python 3.8 compatibility

* Finalized location names. Fixed lambda bug.

* v1.0

* Removed excess Docs and Changed item counts.

* Update Archipelago Unittests.run.xml

* Started working on Docs.

* Updated item arrays for client

* Fix client bugs

* Flake8 fixes + Yaml fix

* Update speed boots

* v1.1

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* v1.0.4

* Initial Ruff format

* Formatting part-2

* More formatting + Doc changes

* Updated Docs

* Move client to root

* Update Client Name

* Make Imports Relative

* Add new yaml options + rename obelisks

* v1.1 Initial Upload

* v1.1.0 + some additions

* Client Update

* Client Updates

* pre-v1.1.1

* Update GauntletLegendsClient.py

* pre-1.2

* 1.2.0

* v1.2.1

* Client fixes

* v1.3.0

* v1.4.0

* v1.4.1

* Merged Changes

* Experimental

* pre-v1.5.0

* Changes

* v1.5.0

* Update GauntletLegendsClient.py

* Death Update

* pre v1.5.1

* Obelisk rule changes

* pre-v1.5.1

* Update GauntletLegendsClient.py

* v1.5.1

* v1.5.1

* Update GauntletLegendsClient.py

* Small fix

* Update GauntletLegendsClient.py

* v1.5.2

* pre-v1.6.0

* pre-v1.6.0

* v1.6.0

* Update

* Update GauntletLegendsClient.py

* Test

* pre-v1.6.0

* Client Updtaes

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Update Rom.py

* Update GauntletLegendsClient.py

* Update Rom.py

* Added Logging on stage enter

* v1.6.2

* v1.6.2

* Update Rom.py

* Update GauntletLegendsClient.py

* v1.6.3

* Small Update

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Update GauntletLegendsClient.py

* Changes ready for review

* Update Rom.py

* Remove root client

* Update inno_setup.iss

* Update client

* Initial Review Refactor

* Update Arrays.py

* Review refactor

* Update __init__.py

* Small tweaks after test generations

* Update GauntletLegendsClient.py

* Implement new check_locations() function

* Add name for Skorn to level_names Dict

* Fix for #4671

* Update __init__.py

* Adjust item frequency + Place 50% of filler as local

* Oops I modified the itempool after create_items teehee

* Update __init__.py

* Check MD5 hash before patching ROM

* Update __init__.py

* Fix get_filler_item_name()

* Update MD5 check to built in method in FilePath

* Update Rom.py

* Update worlds/gl/Rom.py

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

* WIP Generation fixes

* v1.7.1

* Update Archipelago Unittests.run.xml

* 0.7.2

* Update GauntletLegendsClient.py

* Review Edits

* Update World description

* Fuzz fixes

* Update GauntletLegendsClient.py

* Make static data immutable

* Update option comparisons since no longer a Choice

* Update worlds/gl/Rom.py

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

* Update __init__.py

* Initial Update commit

* Progress

* Remove un-used import + Add manifest

* Add missing docstring for client command

* Progress

* pre-v2.0.0-BETA

* Updates

* Progress

* Working Generations

* Working IN-Game

* v2.0.0-BETA

* Create archipelago.json

* v2.0.1

* v2.0.1 FINAL

* pre v2.0.2

* Delete report.json

* Cleanup

* pre v2.0.2

* v2.0.2

* PR Ready

* Update Items.py

* Delete fuzz.py

* Update Items.py

* Update GauntletLegendsClient.py

* Fix scouting disabled locations

* Client Updates

* v2.0.3

* Increment World Version

* Docstring + slot_data updates

* Option name update + Remove redundant option

* Code Cleanup

* Trenches is level 24

* Frequencies

* More cleanup and removal of stale code. MISSING BASEPATCH

* v2.1.0

* Minor enemy changes

* Small fix

* Update Rom.py

* v2.1.1

* Small fixes

* Update basepatch.bsdiff4

* Update GauntletLegendsClient.py

* Socket error clarity

* Delete settings.local.json

* Update GauntletLegendsClient.py

* v2.1.2 fixes

* Boss portal fix

* Small client fix + Item Frequencies

* Update archipelago.json

* Fix for games playing with max difficulty lower than 4 not sending locations

* Update archipelago.json

* v2.1.5

* Fix for Chimera's lair mirror shard

* Update archipelago.json

* Mod Re-Base + Queue limits + Item Receiving fixes

* Options cleanup and newlines for some files

* Include v2.x features in docs

* Make sure players has at least 1 item

* v2.1.6

* Update archipelago.json

* Review Edits + Client Tweaks

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2026-07-11 19:26:27 +02:00
qwint f9c5868f7b ModuleUpdate: Add core requirements.txt as a constraint file to pip calls (#6309)
* Add core requirements.txt as a constraint file to pip calls

* remove --upgrade as requested

* Update ModuleUpdate.py
2026-07-11 18:27:58 +02:00
Doug Hoskisson fde71f66f3 Core: typing and cleaning, mostly in Fill.py (#4459)
* Core: typing and cleaning, mostly in `Fill.py`

* fix typing of things we get out of plando blocks

* clean getting multiworld from kwargs
add a list type
clean indentation

* fix issues with merge conflict resolution

* some things found after merge
2026-07-11 13:26:19 +02:00
Uriel e6e0bc3042 WebHost: Config option for custom port ranges v2 (#6009)
* Added ability to define custom port ranges the WebHost will use for game servers, instead of pure random.

* - Added better fallback to default port range when a custom range fails
- Updated config to be clearer

* Added ability to define custom port ranges the WebHost will use for game servers, instead of pure random.

* - Added better fallback to default port range when a custom range fails
- Updated config to be clearer

* Updated soft-fail message

* Removed dead import from customserver.py

* Update requirements.txt

Settings requirements to main core branch

* fix what reviewers said and add some improvements

* remove unused argument

* try fixing test with try

* use yaml lists instead of string for config

* fix value type bug on ephemeral type

* reuse sockets with websockets api instead of opening and closing them

* add used ports cache and filter used ports when looking for ports

* fix port randomizer

* Apply suggestions from code review

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

* fix some reviews

* use weights for random port and remove more-itertools

* fix net_connections not working on macOS

* rename variables and functions

* lazy init `get_used_ports`

* change `game_ports` to be `tuple`

* fix last_used_ports not being updated locally

* fix random choices and move game_port conversion into tuple

* Apply suggestions from code review

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

* use a named tuple on parse_game_ports

* only use ranges

* do it the duck way

* this should check all usable ports before failing

* fix while loop

* add return type to weighted random

* Update WebHostLib/customserver.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* simplify tuple conversion check

* add tests

* reformat file and change `create_random_port_socket` test

* add more test cases for parse_game_ports

* try to prevent busy-looping on create random port socket when doing test

* simplify parse game port tests to one assertListEqual

* make the range lesser for port test

* reduce range on macOS

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update WebHostLib/customserver.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Apply suggestions from code review

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

* remove unused import

* Update WebHostLib/customserver.py

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

* use generator expressions

* check for 0-tuple

* use some kind of shuffled queue

* update tests

* refactor new port handling into a class (#1)

* change time to monotonic

* Update docs/webhost configuration sample.yaml

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* add psutil 7.2.2 as requirement

* Update WebHostLib/requirements.txt

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: Lexipherous <jasonnlelong@gmail.com>
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2026-06-20 17:36:18 +02:00
Emily 5ccef9802a WebHost: fix Authors on supported game page (#6218) 2026-06-07 18:21:30 +02:00
CosmicWolf 09aba95c03 Factorio: Fixed a needlessly small bug that does not affect gameplay (#6147) 2026-06-03 17:53:37 +02:00
Ishigh1 2a8ebe2399 Core+RB: Make has_from_list, has_from_list_unique, has_group and has_group_unique return True if count <= 0 and list is empty (#6240) 2026-06-03 08:20:04 +02:00
Ishigh1 ad0af18f51 Rule Builder: Resolve empty and() to True_() instead of False_()(#6239) 2026-06-02 09:10:43 +02:00
Justus Lind 65277a50ce Muse Dash: Update to Touhou Mugakudan -V- (#6234) 2026-06-01 22:38:23 +02:00
dependabot[bot] d241829585 Bump mistune from 3.2.0 to 3.2.1 in /WebHostLib (#6185)
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 23:55:57 +02:00
CosmicWolf b24b3d10b6 Factorio: Locale changes (#5908)
---------

Co-authored-by: Joris Santen <joris.j.vansanten@student.hu.nl>
Co-authored-by: lepideble <147614625+lepideble@users.noreply.github.com>
2026-05-31 01:49:39 +02:00
Fabian Dill 787ddc400c Core: Bump version from 0.6.7 to 0.6.8 (#6114) 2026-05-27 03:26:30 +02:00
qwint 545171f3f4 Core: reset log level when using pyximport (#6212) 2026-05-20 09:12:54 +02:00
Jérémie Bolduc d0abfeb88b The Messenger: Fix portal shuffle logic not using unlocked portals (#6194) 2026-05-18 13:53:48 +02:00
Will Morrow b575983599 SM64: Change power star item classification to be deprioritized (#6191) 2026-05-17 23:44:40 +02:00
Jérémie Bolduc d8d148ac13 Stardew Valley: Bypass CollectionState overhead by just calling prog_items (#6065) 2026-05-17 23:41:34 +02:00
Silvris 24f75ba072 Settings: validate FilePath hash on use (#5854) 2026-05-17 23:40:08 +02:00
Katelyn Gigante 38e77e1b46 Factorio: Remove the need to have two separate installs (#4221) 2026-05-16 18:29:30 +02:00
JaredWeakStrike 799e0b7b0f KH2: fix ice cream double counting for logic (#6068) 2026-05-11 02:16:20 +02:00
Justus Lind 4ef1fb7630 Muse Dash: Convert to Rule Builder (#6166)
---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
2026-05-09 21:25:34 +02:00
Alchav 0cd81ff500 A Link to the Past: We Have Enemizer at Home (#6153) 2026-05-09 17:07:46 +02:00
Duck 55a1b12cb7 CommonClient: Make hint cost (shown in server label) update (#6149) 2026-05-09 17:01:22 +02:00
Duck 3fcd337f65 WebHost: Add authors to supported games page (#6123) 2026-05-09 17:00:00 +02:00
Duck 7a5acfeceb Core: Make manifest accessible as a world attribute (#6122) 2026-05-09 16:59:05 +02:00
Bryce Wilson 06dc1b897c Pokemon Emerald: Switch to rule builder (#5923) 2026-05-09 16:58:01 +02:00
Ishigh1 4a28888a66 Rule Builder: Implement AtLeast (#6085)
---------

Co-authored-by: Ian Robinson <ianronrobinson@gmail.com>
2026-05-09 16:56:10 +02:00
Christina d0407a74d6 Docs: Added new documentation for DS3's enemy randomization (random_enemy_preset) (#5738)
---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2026-05-09 16:54:20 +02:00
josephwhite e0810022f8 WebHost: Fix adding weighted text options (#5116) 2026-05-09 16:51:56 +02:00
Mysteryem 29a6f40c2b Blasphemous: Increase logic performance (#4585)
---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2026-05-09 16:50:34 +02:00
josephwhite 2bd572c23d Super Mario 64: Basic testing (#4335)
---------

Co-authored-by: Yussur Mustafa Oraji <N00byKing@hotmail.de>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2026-05-09 16:46:53 +02:00
Scipio Wright b59e52a103 Options: Remove the "" from the start inventory examples (#6165)
---------

Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>
2026-05-05 16:14:19 +02:00
Silvris 6d9d340c71 Launcher: display window for failed world loads if any (#6058) 2026-05-05 07:30:47 +02:00
qwint 4486510fbc Options: make template generation log the failing world if one fails (#6176) 2026-05-02 17:53:37 +02:00
Fabian Dill a68109f5a7 WebHost: Add deletion of old content to cleanup function (#6119)
---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-05-01 22:38:46 +02:00
Alchav 234b0581c9 ALttP: Fix starting mail and shield equipment from start inventory (#6154) 2026-05-01 11:30:35 +02:00
Justus Lind 798eeaad91 Muse Dash: Update to DASH AND SHOOT (#6163) 2026-05-01 11:29:59 +02:00
qwint 1875062099 Undertale: Handle zero arg /auto_patch (#5130) 2026-04-29 22:29:41 +02:00
Seldom 9da3c29990 Terraria: fix failing to generate with Calamity enabled and full accessibility on Mechanical Bosses goal #5787 2026-04-29 21:25:38 +02:00
palex00 90b72c0fa5 Docs: Add missing space to plando doc #6060 2026-04-29 21:25:14 +02:00
Mysteryem fd8f3fcd8b The Messenger: Add missing indirect conditions (#6101) 2026-04-29 21:24:41 +02:00
Jérémie Bolduc d36ba62243 The Messenger: Add missing rules for Key of Strength#6124 2026-04-29 21:24:10 +02:00
Ixrec 8ac335f92d Docs: Rewrite get_filler_item_name's docstring to avoid implying it's for filling all unfilled locations #5956 2026-04-29 21:22:35 +02:00
qwint 74a0cd6022 MultiServer: update help doc on auto_shutdown#5917 2026-04-29 21:21:42 +02:00
Sophia Caspe 31f5a6c4ea Docs: Rule Builder Filtered Example Fix (#6152) 2026-04-29 21:19:32 +02:00
River Buizel be9c7b1728 KH2: Update setup_en.md (#6129) 2026-04-29 21:18:59 +02:00
massimilianodelliubaldini a0236b2d74 Jak1: yamlless UT support (#6066) 2026-04-29 21:18:21 +02:00
threeandthreee af39d7926a LADX: doc update (#6050)
* update gfxmod doc

* romless generation

* Update setup_en.md

Co-authored-by: RoobyRoo <thegreenrobby@gmail.com>

---------

Co-authored-by: RoobyRoo <thegreenrobby@gmail.com>
2026-04-29 21:15:57 +02:00
Ian Robinson a48bf0195f Docs: Make RB OptionFilter docs clearer (#6028)
* add more docs for option filters

* reword the whole things

* style guide
2026-04-29 21:14:34 +02:00
Sophira 73856f63c8 Docs: Mention APQuest as a reference (#6000)
The APQuest implementation of an APWorld is incredibly useful and should
really have a mention here.
2026-04-29 21:12:04 +02:00
CookieCat d5cc201138 AHIT: Update FAQ in setup guide (#5975) 2026-04-29 21:11:36 +02:00
Ixrec 4346191c55 Docs: Add a warning to world api.md#events that you want StatusUpdate, not events, for actually sending the goal (#5957) 2026-04-29 21:10:26 +02:00
Mysteryem 0601494e39 Pokemon RB: Fix mutating global item_groups in get_filler_item_name() (#5947)
The `banned_items` variable was the `"Unique"`list within the
`item_groups` global. `get_filler_item_name()` could then mutate
`banned_items` through `banned_items.append("Poke Doll")` and
`banned_items += item_groups["Vending Machine Drinks"]`, causing
the contents of the `item_groups` global to be mutated.

This has been fixed by making `banned_items` a shallow copy of
`item_groups["Unique"]`.
2026-04-29 21:08:16 +02:00
Yussur Mustafa Oraji 78937054de sm64ex: Recommend Flatpak for Linux users (#5897) 2026-04-29 21:07:52 +02:00
UCSA 59674926ed Docs: added an italian translation of the setup guide for Muse Dash (#5834)
* Create setup_it.md

Created an italian translation of the setup for muse dash. I could not find direct references to translating a setup so I am assuming this is the correct way to submit a translation.
While I am a native in italian and I would consider myself fluent in english, I am not a translator so mistakes could have been made.

* Update __init__.py
2026-04-29 21:07:32 +02:00
Oshroth b716096a4b SML2: Send logic affecting randomiser options as slot_data (#5751)
* Send logic affecting randomiser options as slot_data

* SML2 - Universal Tracker support

* Remove debug logs

* Only check generation_is_fake once

---------

Co-authored-by: alchav <alchav@jalchavware.com>
2026-04-29 21:01:23 +02:00
el-u c851eff521 lufia2ac/docs: fix some errors (#5618)
Co-authored-by: Ludwig <80899010+wordfcuk@users.noreply.github.com>
2026-04-29 20:56:11 +02:00
Duck 01033940d6 MMBN3: Add spaces in concatenated string#5689 2026-04-29 20:55:32 +02:00
Mrks 15561e1e2d Generate: Added "quantity" option for player yamls to use multiple times a single yaml (#4948)
* Added  option for player yamls.

* Extended documentation

* Minimized value extraction.

* Added allow_quantity option to host.yaml

* Added  option for player yamls.

* Extended documentation

* Minimized value extraction.

* Added allow_quantity option to host.yaml

* Update settings.py

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

* Update Generate.py

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

* Added allow_quantity as application argument.

* Quantity > 1, allow_quantity = false -> error instead of silent 1

* Added check for quantity <= 0; reverted settings import change

* Update Generate.py

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2026-04-29 20:49:19 +02:00
Ian Robinson 9c78edc764 Rule Builder: Fix rule hash collisions (#6169)
* fix rule hash collisions

* just let the dict do the work
2026-04-29 08:21:09 +02:00
lepideble d2395de9fa Factorio: move communication savegame to user dir (#5646) 2026-04-27 14:06:19 +02:00
black-sliver d41cec6494 CI: update softprops/action-gh-release to v3.0.0 (#6162)
The update will be required once GH drops support for Node 20.

Also enables new options the action has now.
2026-04-26 00:44:48 +02:00
NewSoupVi dc911399fb The Witness: More control over progressive symbols (#3961)
* More control over progressive symbols!

* Cleanup crew

* lol

* Make it configurable how second stage items act on their own

* only let independent symbols work if they're not progressive

* revert defaults

* Better description

* comment for reviewers

* A complicated docstring for a complicated function

* More accurate

* This probably works more generically

* Actually, this would cause other issues anyway, so no need to make this generic yet

* :/

* oops

* Change the system to use collect/remove override so that plando and start inventory work correctly

* Vi stop doing that thing challenge

* Make SecondStateSymbolsActIndependently an OptionSet

* jank

* oop

* this is why we make unit tests I guess

* More unit tests

* More unit tests

* Add note about the absence of Rotated Shapers from the independent symbols optionset

* More verbose description

* More verbose description

* slight reword

* Ruff ruff :3 I am a good puppy <3

* Remove invis dots

* oops

* Remove some unused symbols

* display name

* linecounting -> discard

* Make all progressive chains always work

* Unfortunately, this optimisation is now unsafe with plando :(

* oops

* This is now a possible optimisation

* optimise optimise optimise

* optimise optimise optimise

* fix

* ruff

* oh

* fixed frfr

* mypy

* Clean up the tests a bit

* oop

* I actually like this better now, so I'm doing it as default

* Put stuff on the actual item class for faster collect/remove

* giga oops

* Make proguseful work

* None item left beef

* unnecessary change

* formatting

* add proguseful test to progressive symbols tests

* add proguseful test to progressive symbols tests

* clean up collect/remove a bit more

* giga lmfao

* Put stuff in option groups

* Add the new symbol items to hint system

* bump req client version

* fix soft conflict in unit tests

* fix more merge errors
2026-04-18 18:07:23 +02:00
NewSoupVi 0b38065123 APQuest: Fix Easy Mode boss having too much health to beat (#6146)
* Change the boss' health to 2 in easy mode, adjust boss graphics to reflect this behavior

* graphics formatting
2026-04-18 17:08:21 +02:00
NewSoupVi a6c1347102 The Witness: Make "Early Good Item" an OptionSet allowing Symbols, Doors and Obelisk Keys (#3804)
* New solution to that plando issue

* better

* Another warning

* better comment

* Best of both worlds I guess?

* oops

* Smarter code reuse

* better comment

* oop

* lint

* mypy

* player_name

* add unit test

* oh

* Rebrand time baby

* This fits on one line

* oop

* Update worlds/witness/__init__.py

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

* mypy

* Reorganize some doors according to medic's suggestions

* This should make it much faster (Thanks Medic)

* Town Doors works because of Church being a check always, Church Entry feels really bad tho

* Only add Desert Entry if there are no control panels stopping you

* No overlap here, so why is it a set

* Idk everything's kinda good without symbol shuffle

* Just make sure, I guess

* This makes way more sense doesn't it

* Oh, this is probably important

* oop

* loc

* oops 2

* ruff

* that was already in there

* Change the door picking a bit further

* some renaming

* slight wording change

* Fix

* Move it all to a new file

* ruff

* mypy

* .

* Make sure we're only adding as many tutorial checks as necessary

* ruff

* These checks aren't necessary, as the final list gets culled to only existing items anyway. It saves CPU cycles, but this is nicer for future compatibility

* Special handling for caves shortcuts

* 120 chars

* Update worlds/witness/place_early_item.py

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

* Clean up Windmill & Theater cases

* Make early_symbol_item removed instead

* Add early_good_item to presets

* replace double None checks with casts

* That doesn't exist anymore

* Mypy thing

* Update the doors again a bit

* Pycharm pls

* ruff

* forgot one

* oop

* Is it finally right?

* Update options.py

* Fix with new Panel Keys

* Hopefully fix crash when one of the types runs out when the others haven't yet

* oops

* Medic suggestion

* unused import

* Update place_early_item.py

* Update __init__.py

* Update __init__.py

* Update options.py

* Add possible types to option desc

* Make that include all Tutorial (Inside) checks

* Update __init__.py

* Update test_early_good_item.py

* Update test_early_good_item.py

* Slight cleanup

* fix no tutorial locations being picked up by tutorial location size check

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2026-04-18 13:52:38 +02:00
NewSoupVi 0a742b6c98 Options: Add more Option unit tests, add generic Option.__eq__, make cull_zeroes available to all OptionCounters #5905 2026-04-18 13:49:19 +02:00
NewSoupVi 66712bbd87 APQuest: Switch to Rule Builder (#5906)
* CachedRuleBuilderWorld

* CachedRuleBuilderWorld

* APQuest Rule Builder finished

* Added comment

Add a rule to check if the player has a Sword to destroy bushes.

* Bump version + typo fix

* Update worlds/apquest/rules.py

Co-authored-by: Ian Robinson <drtchops@users.noreply.github.com>

* Address Tchops' review comments

---------

Co-authored-by: Ian Robinson <drtchops@users.noreply.github.com>
2026-04-18 13:48:32 +02:00
black-sliver 5f9e38b783 Test: add test for unpickling NetUtils enums (#5326)
* Test: add test for unpickling NetUtils enums

This verifies that Utils.ByValue either works or is not required,
and once we drop ByValue, this validates that future Python
versions do not break our Enums again.

* Test: NetUtils enum switch to more direct pickle.dumps

It's probably better to use the direct interface in case
restricted_dumps does some funky stuff in the future.

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

* Test: NetUtils enum fix import for change

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-04-18 02:15:37 +02:00
Duck bdde2140b3 Core: Remove worlds.generic.PlandoItem (#6035) 2026-04-18 01:24:25 +02:00
qwint 42d24b4869 HK: add manifest (#6057) 2026-04-18 01:23:19 +02:00
black-sliver 14aa6571de WebHost: Fix: strict suuid converter (#6130)
* WebHost: disallow '=' and whitespace in SUUID and append the correct number of '='

This makes Room IDs unambiguous and avoids breaking if b64decode ever becomes strict.

* Tests, WebHost: add SUUID tests

* Tests, WebHost: add edge cases to SUUID tests
2026-04-13 20:17:27 +00:00
sudo-nano 37d32a10b1 Factorio: Improve English instructions (#5306) 2026-04-13 11:19:23 +02:00
Silvris 0ef2ed5b81 Docs: fix misinformation in tests.md (#6125) 2026-04-09 21:27:39 +02:00
PoryGone b7c4fcb4c6 DKC3: Delete DKC3 (#6097) 2026-04-08 21:35:43 +02:00
williamronn bcd3f9a74c WebHost: 2025 to 2026 (#6094) 2026-04-07 22:16:39 +02:00
Trevor L 6262235161 Bomb Rush Cyberfunk: Fix logic requirement increasing after collecting item (#6105) 2026-04-07 22:15:47 +02:00
Silvris 030cb4b578 Various Worlds: purge world: MultiWorld (#5973) 2026-04-07 22:13:31 +02:00
agilbert1412 36bab6f52a Stardew Valley: 0.6.7 Day 1 fixes (#6098)
- The Shane and Sebastian Portrait filler items werent tagged properly.
- The Beach Farm adds one secretsanity check, so it needs to be in the allsanity preset
- The Allsanity preset is renamed to "Maxsanity" to encourage better defined terminology and Allsanity means something else
- The "All Random" preset has been removed entirely. It has been the cause of too many beginner footguns over the years. People can still achieve the effect manually, but at least they'll have to try a little bit to ruin their own experience.
2026-04-07 21:38:04 +02:00
Duck e0cfef3407 APWorld Builder: Add option to skip opening output folder (#5847) 2026-04-05 19:50:17 +02:00
Duck bb2a775c05 WebHost: Fix hosting with invalid worlds installed (#5648) 2026-04-04 07:52:27 +02:00
palex00 427b147818 APQuest: Add Link to Poptracker Pack to Setup Guide (#6089)
* Update setup_en.md

* Update setup_de.md
2026-04-01 22:43:14 +02:00
NewSoupVi 3f3c343fb3 APQuest: Fix focus issues (#6090)
* My best attempt at fixing focus issues on Android

* didn't mean to remove that
2026-04-01 22:42:08 +02:00
Bryce Wilson debe4cf035 Pokemon Emerald: Bump version (#6083) 2026-04-01 19:18:42 +02:00
Silvris 68f25f4642 MM3: Bump world version (#6088) 2026-04-01 19:18:11 +02:00
NewSoupVi 3c4af8f432 APQuest: Tap to move (#6082)
* Tap to move

* inputs

* cleanup

* oops
2026-03-31 20:55:52 +02:00
NewSoupVi 5360b6bb37 The Witness: (Unbeatable seed) Ensure Desert Laser Redirection is required when the box is rotated (#5889)
* Unbeatable seed: 11 lasers + redirect when the box is rotated

* naming
2026-03-31 00:31:05 +02:00
black-sliver 2ee20a3ac4 CI: set permissions, update and pin actions, CodeQL for actions (#6073)
* CI: reduce default permissions to minimum

* CI: update pin actions

Most of them. CodeQL and action-gh-release is untouched for now.
Immutable actions and actions/* are pinned to version,
other actions are pinned to hash.

* CI: make use of archive: false in upload-artifact

also set compression level and error behavior for scan-build upload.

* CI: update codeql and enable scanning actions
2026-03-30 21:46:43 +02:00
Ian Robinson c640d2fa24 Rule Builder: Add field resolvers (#5919) 2026-03-30 18:19:10 +02:00
James White 58a6407040 SMW: Prevent receiving your own traps while aliased (#5763) 2026-03-30 17:27:10 +02:00
EdricY ba7ca0bd23 Options Creator: bind free text set_value to text instead of on_text_validate (#5915) 2026-03-30 17:25:25 +02:00
Louis M bdbf72f148 Aquaria: Fixing bug where Urchin Costume is not a progression damaging item (#5998) 2026-03-30 01:40:05 +02:00
Jarno 2b46df90b4 Satisfactory: Fixed buildings missing from goal check (#5772) 2026-03-30 00:46:01 +02:00
NewSoupVi 88dc135960 APQuest: Various fixes (#6079)
* Import Buffer from typing_extensions instead of collections.abc for 3.11 compat

* always re-set sound volumes before playing

* fix game window scaling if parent is vertical

* make default volume lower
2026-03-30 00:32:06 +02:00
Duck 95f696c04f WebHost: Remove space before comma separators in tutorial authors (#5999)
* Remove space before comma

* Factorio authors update

* Simplify template
2026-03-30 00:19:54 +02:00
el-u 96277fe9be lufia2ac: update CollectionRule import (#5936) 2026-03-29 23:37:53 +02:00
XxDERProjectxX a7a7879df4 Satisfactory: bug fix in __init__.py (#5930)
Solved indentation error to return to intended functionality
2026-03-29 23:34:21 +02:00
Alchav 773f3c4f08 Super Mario Land 2: Fix Space Zone 2 Logic (#6025) 2026-03-29 23:25:46 +02:00
agilbert1412 139856a573 Stardew Valley: Fixed an issue where some specific option combinations could create more items than locations (#6012)
* - Improved the dynamic locations count algorithm to take into account the nature of various heavy settings in both directions

* - Fixes from Code Review

* - We're only testing for sunday locations, might as well only take sunday locations in the list to test

* - One more slight optimization

* - Added consideration for bundles per room in filler locations counting

* - Registered some more IDs to handle items up to 10
2026-03-29 23:21:29 +02:00
Noa Aarts a1ed804267 Stardew Valley: trimmed lucky purple shorts need gold to make (#6034)
The current logic only requires the shorts and a sewing machine, but a
gold bar is also necessary
2026-03-29 23:20:24 +02:00
agilbert1412 2d58e7953c Stardew valley: Four small fixes (#6055)
* - Fixed the Dr Seuss Bundle asking for tigerseye (mineral) instead of tiger trout (fish)

* - Made blue grass starter more consistent

* - Fragments of the past does not rely on ginger island

* - Removed legacy hard coded strange bun recipe that messed with chefsanity logic
2026-03-29 23:20:00 +02:00
Flit 393ed51203 Messenger: Require Wingsuit to traverse Dark Cave (#6059) 2026-03-29 23:16:34 +02:00
Mysteryem 03c9d0717b Muse Dash: Fix nondeterministic generation with include_songs (#6040)
The include_songs option is an OptionSet, whose value is a set, but was being iterated to produce self.included_songs. Sets are unordered and may have a different iteration order each time a python process is run. This meant that the order of the elements in self.included_songs could differ even when generating with a fixed seed.

This caused nondeterministic generation with the same seed because create_song_pool() deterministically randomly picks songs from self.included_songs, which could be in a different order each time, so different songs could be picked.
2026-03-29 23:12:25 +02:00
Bryce Wilson 5ca50cd8d3 Pokemon Emerald: Fix Latios KeyError (#6056) 2026-03-29 23:10:16 +02:00
Sebastian 36cf86f2e8 Docs: update macOS setup instructions for more specificity on Python version (#6078) 2026-03-29 21:18:03 +02:00
Duck 1705620c4f Launcher: Add konsole to terminal list and rework launch dialog (#5684)
* Make component launching indicate if no terminal window, add konsole

* Attempt to spell better and remove whitespace

* Update terminal priority

* Make helper for clearing LD_LIBRARY_PATH

* Add handling to linux launch

* Hopefully fix setter

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-29 20:07:55 +02:00
black-sliver ffe4c6dd15 Core, Webhost: update and pin dependency versions (#6075) 2026-03-29 19:44:29 +02:00
black-sliver cf47cc67c0 Clients: remove datapackage from persistent_storage ... (#6074)
... next time it gets written to.
This makes loading peristent_storage faster in the future.
2026-03-29 19:43:26 +02:00
Ian Robinson 645f25a94e setup.py: add rule_builder.cached_world to included list (#6070) 2026-03-29 18:29:37 +02:00
qwint 74f41e3733 Core: Make Generate.main only init logging on __main__ (#6069) 2026-03-28 00:58:36 +01:00
Phaneros 4276c6d6b0 sc2: Fixing random fill errors in unit tests (#6045) 2026-03-27 22:45:38 +01:00
Justus Lind 116ab2286a Muse Dash: Add support for Wuthering Waves Pioneer Podcast and Ay-Aye Horse (#6071) 2026-03-27 18:36:36 +01:00
Ian Robinson fb45a2f87e Rule Builder: Fix count resolution when Oring HasAnyCount (#6048) 2026-03-18 18:54:17 +01:00
Fabian Dill 2e5356ad05 Core: other resources guide (#6043)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>
2026-03-18 03:30:22 +01:00
lepideble 8457ff3e4b Factorio: only show fluid boxes on assembling machine 1 when the selected recipe needs fluids (#5412) 2026-03-16 15:17:54 +01:00
Fabian Dill 70fc3e05fb Webhost: port reuse fix & configurable max room timeout (#6033)
* WebHost: make autolauncher max room timeout configurable

* WebHost: launch rooms with assigned port first
2026-03-12 02:48:45 +01:00
Duck d01c9577ab CommonClient: Add explicit message for connection timeout (#5842)
* Change timeout and add timeout-specific message

* Revert open_timeout
2026-03-11 23:46:59 +01:00
qwint 260bae359d Core: Update .gitignore to include an exe setup.py downloads (#6031) 2026-03-11 21:37:00 +01:00
Mysteryem 3016379b85 KH2: Fix nondeterministic generation when CasualBounties is enabled (#5967)
When CasualBounties was enabled, the location names in
`exclusion_table["HitlistCasual"]` would be iterated into
`self.random_super_boss_list` in `generate_early`, but
`exclusion_table["HitlistCasual"]` was a `set`, so its iteration order
would vary on each generation, even with same seed.

Random location names would be picked from `self.random_super_boss_list`
to place Bounty items at, so different locations could be picked on each
generation with the same seed.

`exclusion_table["Hitlist"]` is similar and was already a `list`,
avoiding the issue of nondeterministic iteration order, so
`exclusion_table["HitlistCasual"]` has been changed to a `list` to
match.
2026-03-10 23:06:44 +01:00
Ixrec 03b638d027 Docs: Reword 'could be generated from json' to avoid encouraging slow world loads (#5960) 2026-03-10 20:49:47 +01:00
Exempt-Medic 3c802d03a1 DS3: Use remaining_fill instead of custom fill (#4397)
---------

Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>
2026-03-10 20:11:23 +01:00
Mysteryem a8e926a1a9 Core: Make Generic ER only consider the current world in isolation (#4680) 2026-03-10 20:08:20 +01:00
Rjosephson 56c2272bfd RoR2: Seekers of the Storm (SOTS) DLC Support (#5569) 2026-03-10 20:05:59 +01:00
Fabian Dill 47e581bc30 LttP: add manifest (#6005) 2026-03-10 20:04:27 +01:00
black-sliver 3235863f2e WebHost: add stats show cli command (#5995)
Usage: flask -A "WebHost:get_app()" stats show

Currently only shows sum and top10 biggest games packages.
2026-03-10 19:57:04 +01:00
black-sliver f00d29e072 Tests: fix race in test hosting shutdown (#5987) 2026-03-10 19:56:23 +01:00
Gryphonlady d000c0f265 Docs: Update plando_en.md with item group example (#6024)
* Update plando_en.md with item group example

Added example YAML block for item placement using an item group, including recommendation of use of `true` value with item groups to avoid unintended behaviors, with an example of the same.  Adjustments more than welcome!

* Made clarifying revision to description of Generator handling of item groups

Clarified the behavior of the Generator regarding item creation when item groups are used in plando.
2026-03-10 19:23:25 +01:00
Duck 94136ac223 Docs: Add references to running from source (#6022) 2026-03-10 19:18:03 +01:00
LeonarthCG 72ff9b1a7d Saving Princess: Security fixes for issues detected by Bandit (#6013)
* Saving Princess: absolute paths on suprocess.run

* Saving Princess: more error handling for downloads

* Saving Princess: rework launch_command setting

Apparently subprocess.Popen requires a list for args instead of a string everywhere but in Windows, so the change was preventing the game from running on Linux. Additionally, the game is now launched using absolute paths.

* Saving Princess: prevent bandit warnings

* Saving Princess: remove unnecessary compare_digest

* Saving Princess: fix Linux paths by using which

* Saving Princess: rename launch command setting

Previously, launch_command held a string. Now it holds a list of strings. Additionally, the defaults have changed.
To prevent the wrong type from being used, the setting has been renamed, effectively abandoning the original launch_command setting.

* Saving Princess: fix Linux default command return type

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-10 18:12:48 +00:00
josephwhite 4b37283d22 WebHost: Update UTC datetime usage (timezone-naive) (#4906) 2026-03-10 18:57:48 +01:00
Scipio Wright c3659fb3ef TUNIC: Refactor entrance hint generation (#5620)
* Refactor hint generation

* Remove debug print

* Early out per qwint's comment
2026-03-10 18:55:07 +01:00
Matthew Wells 1a8a71f593 Dark Souls 3: Update location descriptions for Red Tearstone Ring and Hood of Prayer (#5602)
RTSR's description was incorrect and Hood of Prayer was missing its description
2026-03-10 18:54:24 +01:00
Goo-Dang c255ea8fc6 Pokemon Emerald: Dexsanity Encounter Type Option (#6016)
---------

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>
2026-03-10 18:52:59 +01:00
Remy Jette fd81553420 Fix missing } in example_nginx.conf (#6027) 2026-03-10 10:38:02 +00:00
Justus Lind 2c279cef09 Muse Dash: Adds 3 new music packs plus fixes being able to roll songs without a legal way to obtain them (#5698) 2026-03-10 06:11:34 +01:00
josephwhite 07a1ec0a1d Test: Defaults for Options test (#5428) 2026-03-10 05:23:26 +01:00
Jérémie Bolduc 0b6ba103c5 The Messenger: Universal Tracker support (#5344) 2026-03-10 04:56:05 +01:00
Star Rauchenberger 123e1f5d95 Lingo: Fix logic for Near Eight Painting (#6014) 2026-03-09 14:13:45 +01:00
Remy Jette 44e424362e Docs: Don't serve non-static files in example_nginx.conf (#5971)
* Docs: Don't serve non-static files in example_nginx.conf

`try_files` will serve the file as long as it exists, which means I could `GET /autolauncher.py` and be served the file.

* Use root instead of alias, add route for favicon

* Update deploy/example_nginx.conf
2026-03-09 08:51:26 +01:00
Noa Aarts 371db53371 Stardew Valley: morel doesn't spawn in fall secret woods (#6003) 2026-03-08 21:50:34 +01:00
Silvris 5b99118dda Mega Man 3: Implement new game (#5237) 2026-03-08 21:42:06 +01:00
Star Rauchenberger 4bb6cac7c4 Lingo: Add archipelago.json (#6017) 2026-03-08 21:35:12 +01:00
LeonarthCG 99601ccebc Saving Princess: add manifest (#6008) 2026-03-08 21:34:51 +01:00
josephwhite 53956b7d4d OOT: UTC deprecation warning fix (#5983) 2026-03-08 21:34:19 +01:00
GodlFire b38548f89b Shivers: Adds Manifest File (#5918) 2026-03-08 21:33:16 +01:00
Bryce Wilson a8ac828241 Pokemon Emerald: Fix rare fuzzer errors (#5914) 2026-03-08 21:32:40 +01:00
StripesOO7 fc2cb3c961 OoT: change setup-guides to have 2.10 be the minimum version recommended (#5799) 2026-03-08 21:31:48 +01:00
Rosalie 9efcba5323 FF1: Added manifest (#5911) 2026-03-08 21:31:04 +01:00
jamesbrq 9f29859810 MLSS: Fix client auto-connect bug + Client cleanup (#5895) 2026-03-08 21:30:18 +01:00
Suyooo 366fd3712a MM2: Fix /request command help (#5805) 2026-03-08 21:28:44 +01:00
qwint b53f9d3773 Docs: Better document state.locations_checked (#6018) 2026-03-08 00:51:42 +01:00
Silvris 3ecd856e29 MultiServer: fix Windows compatibility (#6010) 2026-03-06 01:41:48 +01:00
390 changed files with 34584 additions and 8544 deletions
-1
View File
@@ -46,7 +46,6 @@ dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
+2
View File
@@ -3,6 +3,7 @@
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/field_resolvers.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
@@ -18,6 +19,7 @@
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../test/webhost/test_suuid.py",
"../worlds/AutoSNIClient.py",
"type_check.py"
],
+4 -2
View File
@@ -14,6 +14,8 @@ env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
permissions: {}
jobs:
flake8-or-mypy:
strategy:
@@ -25,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
@@ -50,7 +52,7 @@ jobs:
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6.2.0
if: env.diff != ''
with:
python-version: '3.11'
+15 -16
View File
@@ -41,9 +41,9 @@ jobs:
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -82,7 +82,7 @@ jobs:
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
@@ -110,18 +110,17 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
archive: false
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
archive: false
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
@@ -129,14 +128,14 @@ jobs:
runs-on: ubuntu-22.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -173,7 +172,7 @@ jobs:
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
@@ -204,17 +203,17 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
archive: false
# TODO: decide if we want to also upload the zsync
if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
archive: false
if-no-files-found: error
retention-days: 7
+16 -8
View File
@@ -17,17 +17,26 @@ on:
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
schedule:
- cron: '44 8 * * 1'
permissions:
security-events: write
jobs:
analyze:
name: Analyze
@@ -36,18 +45,17 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
language: [ 'javascript', 'python', 'actions' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4.35.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4.35.1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -72,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4.35.1
+3 -1
View File
@@ -24,6 +24,8 @@ on:
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
permissions: {}
jobs:
ctest:
runs-on: ${{ matrix.os }}
@@ -35,7 +37,7 @@ jobs:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
+9 -7
View File
@@ -19,6 +19,8 @@ on:
env:
REGISTRY: ghcr.io
permissions: {}
jobs:
prepare:
runs-on: ubuntu-latest
@@ -29,7 +31,7 @@ jobs:
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
- name: Set lowercase image name
id: image
@@ -43,7 +45,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
@@ -92,13 +94,13 @@ jobs:
cache-scope: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -115,7 +117,7 @@ jobs:
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.0.0
with:
context: .
file: ./Dockerfile
@@ -135,7 +137,7 @@ jobs:
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: 'Apply content-based labels'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6.0.1
with:
sync-labels: false
peer_review:
+13 -9
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
@@ -48,9 +48,9 @@ jobs:
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -88,7 +88,7 @@ jobs:
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
@@ -97,13 +97,15 @@ jobs:
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
fail_on_unmatched_files: true
overwrite_files: false # Windows release is usually built by hand
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -114,14 +116,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -157,7 +159,7 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
@@ -165,12 +167,14 @@ jobs:
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
dist/*
fail_on_unmatched_files: true
overwrite_files: false # should never happen; avoids accidentally changing a release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -3
View File
@@ -28,12 +28,14 @@ on:
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
permissions: {}
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
with:
submodules: recursive
- name: Install newer Clang
@@ -45,7 +47,7 @@ jobs:
run: |
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '3.11'
- name: Install dependencies
@@ -59,7 +61,9 @@ jobs:
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: scan-build-reports
path: scan-build-reports
compression-level: 9 # highly compressible
if-no-files-found: error
+4 -2
View File
@@ -14,13 +14,15 @@ on:
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
permissions: {}
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.11"
+6 -4
View File
@@ -29,6 +29,8 @@ on:
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
permissions: {}
jobs:
unit:
runs-on: ${{ matrix.os }}
@@ -51,9 +53,9 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
@@ -78,9 +80,9 @@ jobs:
- {version: '3.13'} # current
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
+1
View File
@@ -45,6 +45,7 @@ EnemizerCLI/
/SNI/
/sni-*/
/appimagetool*
/VC_redist.x64.exe
/host.yaml
/options.yaml
/config.yaml
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="APQuest Tests" type="tests" factoryName="py.test">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="AP_TEST_WORLDS" value="apquest" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;$PROJECT_DIR$/worlds -q --continue-on-collection-errors&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/test&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>
+13 -4
View File
@@ -8,10 +8,10 @@ import secrets
import warnings
from argparse import Namespace
from collections import Counter, deque, defaultdict
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set as AbstractSet
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
from typing import (Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -514,7 +514,7 @@ class MultiWorld():
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
def get_locations(self, player: int | None = None) -> Collection[Location]:
if player is not None:
return self.regions.location_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
@@ -727,6 +727,7 @@ class CollectionState():
advancements: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
"""Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic."""
stale: Dict[int, bool]
allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
@@ -1037,6 +1038,8 @@ class CollectionState():
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
if count <= 0:
return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
@@ -1048,6 +1051,8 @@ class CollectionState():
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
Ignores duplicates of the same item."""
if count <= 0:
return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
@@ -1076,6 +1081,8 @@ class CollectionState():
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group."""
if count <= 0:
return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
@@ -1088,6 +1095,8 @@ class CollectionState():
"""Returns True if the state contains at least `count` items present in a specified item group.
Ignores duplicates of the same item.
"""
if count <= 0:
return True
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
+8 -2
View File
@@ -773,7 +773,7 @@ class CommonContext:
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = parts[1] + '\n\n' + text
text = f"{parts[1]}\n\n{text}" if text else parts[1]
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
@@ -896,6 +896,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
"May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except asyncio.TimeoutError:
ctx.handle_connection_loss("Failed to connect to the multiworld server. Connection timed out.")
except OSError:
ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception:
@@ -1067,7 +1069,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if "players" in args:
ctx.consume_players_package(args["players"])
if "hint_points" in args:
ctx.hint_points = args['hint_points']
ctx.hint_points = args["hint_points"]
if "checked_locations" in args:
checked = set(args["checked_locations"])
ctx.checked_locations |= checked
@@ -1075,6 +1077,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if "permissions" in args:
ctx.update_permissions(args["permissions"])
# Update hint info for local display
if "hint_cost" in args:
ctx.hint_cost = int(args["hint_cost"])
elif cmd == 'Print':
ctx.on_print(args)
-27
View File
@@ -1,23 +1,5 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
@@ -81,15 +63,6 @@ RUN apt-get purge -y \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
+109 -87
View File
@@ -1,8 +1,8 @@
import collections
from collections import Counter, defaultdict, deque
from collections.abc import Iterable, Sequence
import itertools
import logging
import typing
from collections import Counter, deque
from typing import Callable, Literal
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from Options import Accessibility
@@ -12,10 +12,11 @@ from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError):
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
if "multiworld" in kwargs and isinstance(args[0], str):
placements = (args[0] + f"\nAll Placements:\n" +
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
def __init__(self, *args: str | object, **kwargs: object) -> None:
multiworld = kwargs.get("multiworld")
if isinstance(multiworld, MultiWorld) and isinstance(args[0], str):
placements = (args[0] + "\nAll Placements:\n" +
f"{[(loc, loc.item) for loc in multiworld.get_filled_locations()]}")
args = (placements, *args[1:])
super().__init__(*args)
@@ -24,8 +25,9 @@ def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
def sweep_from_pool(base_state: CollectionState,
itempool: Sequence[Item] = (),
locations: Iterable[Location] | None = None) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
@@ -33,9 +35,9 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
return new_state
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: list[Location],
item_pool: list[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: Callable[[Location], None] | None = None,
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
name: str = "Unknown") -> None:
"""
@@ -51,11 +53,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
unplaced_items: list[Item] = []
placements: list[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
swapped_items: Counter[tuple[int, str, bool]] = Counter()
reachable_items: dict[int, deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -64,6 +66,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
placed = 0
while any(reachable_items.values()) and locations:
items_to_place: list[Item]
if one_item_per_player:
# grab one item per player
items_to_place = [items.pop()
@@ -95,13 +98,15 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
break
item_to_place = items_to_place.pop(0)
spot_to_fill: typing.Optional[Location] = None
spot_to_fill: Location | None = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
perform_access_check = (
(not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player))
if single_player_placement
else not has_beaten_game
)
else:
perform_access_check = True
@@ -118,7 +123,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
previous_safe_swap_state_cache: deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
@@ -215,7 +220,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None)
for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
if (
multiworld.worlds[placement.item.player].options.accessibility != "minimal" and
not placement.can_reach(state)
):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -255,14 +263,14 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str = "Remaining",
locations: list[Location],
itempool: list[Item],
name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False,
check_location_can_fill: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
unplaced_items: list[Item] = []
placements: list[Location] = []
swapped_items: Counter[tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
@@ -270,16 +278,17 @@ def remaining_fill(multiworld: MultiWorld,
if check_location_can_fill:
state = CollectionState(multiworld)
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
else:
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
return location_to_fill.item_rule(item_to_fill)
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
spot_to_fill: Location | None = None
# going through locations in the same order as the provided `locations` argument
for i, location in enumerate(locations):
if location_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content,
@@ -335,7 +344,7 @@ def remaining_fill(multiworld: MultiWorld,
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory:
last_batch = []
last_batch: list[Item] = []
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
@@ -354,8 +363,8 @@ def remaining_fill(multiworld: MultiWorld,
def fast_fill(multiworld: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
item_pool: list[Item],
fill_locations: list[Location]) -> tuple[list[Item], list[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
multiworld.push_item(location, item, False)
@@ -369,11 +378,14 @@ def accessibility_corrections(multiworld: MultiWorld,
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
minimal_players = {player
for player in multiworld.player_ids
if multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [
location
for location in multiworld.get_locations()
if location.player in minimal_players and not location.can_reach(maximum_exploration_state)
]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
@@ -388,33 +400,36 @@ def accessibility_corrections(multiworld: MultiWorld,
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations: Iterable[Location]) -> None:
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
def forbid_important_item_rule(item: Item) -> bool:
return not ((item.classification & 0b0011) and
multiworld.worlds[item.player].options.accessibility != "minimal")
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(multiworld: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
fill_locations: list[Location],
itempool: list[Item]) -> tuple[list[Location], list[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
early_items_count: dict[tuple[str, int], list[int]] = {}
for player in multiworld.player_ids:
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
for item in items:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
early_locations: list[Location] = []
early_priority_locations: list[Location] = []
loc_indexes_to_remove: set[int] = set()
base_state = multiworld.state.copy()
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
base_state.sweep_for_advancements(locations=(loc
for loc in multiworld.get_filled_locations()
if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -424,11 +439,11 @@ def distribute_early_items(multiworld: MultiWorld,
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
early_prog_items: list[Item] = []
early_rest_items: list[Item] = []
early_local_prog_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
early_local_rest_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
item_indexes_to_remove: set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
@@ -486,7 +501,7 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
panic_method: Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
@@ -501,9 +516,9 @@ def distribute_items_restrictive(multiworld: MultiWorld,
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
filleritempool: typing.List[Item] = []
progitempool: list[Item] = []
usefulitempool: list[Item] = []
filleritempool: list[Item] = []
for item in itempool:
if item.advancement:
@@ -515,7 +530,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
locations: dict[LocationProgressType, list[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
for loc in fill_locations:
@@ -526,17 +541,17 @@ def distribute_items_restrictive(multiworld: MultiWorld,
excludedlocations = locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
lock_later = []
lock_later: list[Location] = []
def mark_for_locking(location: Location):
def mark_for_locking(location: Location) -> None:
nonlocal lock_later
lock_later.append(location)
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
regular_progression = []
deprioritized_progression = []
regular_progression: list[Item] = []
deprioritized_progression: list[Item] = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
@@ -622,7 +637,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
if excludedlocations:
raise FillError(
@@ -634,7 +649,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool,
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
unplaced = restitempool
unfilled = defaultlocations
@@ -653,18 +668,19 @@ def distribute_items_restrictive(multiworld: MultiWorld,
for player in multiworld.player_ids:
if more_locations[player]:
logging.error(
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
f"Player {multiworld.get_player_name(player)} had "
f"{more_locations[player]} more locations than items.")
elif more_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
if unfilled:
raise FillError(
f"Unable to fill all locations.\n" +
"Unable to fill all locations.\n"
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
f"Unable to place all items.\n" +
"Unable to place all items.\n"
f"Unplaced items({len(unplaced)}): {unplaced}"
)
@@ -714,7 +730,7 @@ def flood_items(multiworld: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
raise FillError('No more progress items left to place.', multiworld=multiworld)
raise FillError("No more progress items left to place.", multiworld=multiworld)
# find item to replace with progress item
location_list = multiworld.get_reachable_locations()
@@ -737,7 +753,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
balanceable_players: dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
@@ -748,15 +764,15 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
checked_locations: set[Location] = set()
unchecked_locations: set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
total_locations_count: Counter[int] = Counter(
location.player
for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
reachable_locations_count: dict[int, int] = {
player: 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
@@ -770,7 +786,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
moved_item_count: int = 0
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
locations: set[Location]) -> set[Location]:
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -818,7 +834,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
balancing_unchecked_locations = unchecked_locations.copy()
balancing_reachables = reachable_locations_count.copy()
balancing_sphere = sphere_locations.copy()
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
candidate_items: dict[int, set[Location]] = defaultdict(set)
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
@@ -845,11 +861,11 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
unlocked_locations: dict[int, set[Location]] = defaultdict(set)
for l in unchecked_locations:
if l not in balancing_unchecked_locations:
unlocked_locations[l.player].add(l)
items_to_replace: typing.List[Location] = []
items_to_replace: list[Location] = []
for player in balancing_players:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
@@ -955,6 +971,12 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
# TODO: This doesn't handle the plando API correctly
# It says `world` can be other containers other than list,
# but this is checking specifically for list.
# (But it's not simple to just change the check to Iterable,
# because that will catch the str case too early.)
# (And it will be broken without failing unit tests. So also TODO: unit test this.)
if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player}
elif target_world is True: # target any worlds besides own
@@ -984,7 +1006,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds
items: list[str] | dict[str, typing.Any] = block.items
items = block.items
if isinstance(items, dict):
item_list: list[str] = []
for key, value in items.items():
@@ -1045,8 +1067,8 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
early_locations: dict[int, list[Location]] = defaultdict(list)
non_early_locations: dict[int, list[Location]] = defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
@@ -1054,7 +1076,7 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
removed: list[PlandoItemBlock] = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
@@ -1078,8 +1100,7 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
block.count["target"] = multiworld.random.randint(block.count["min"], block.count["max"])
if not block.count["target"]:
removed.append(block)
@@ -1117,7 +1138,7 @@ def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[Plando
maxcount = placement.count["target"]
from_pool = placement.from_pool
item_candidates = []
item_candidates: list[Item] = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
@@ -1143,8 +1164,9 @@ def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[Plando
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
candidates = [candidate
for candidate in locations
if candidate.item is None and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
+16 -4
View File
@@ -40,6 +40,8 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
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
parser.add_argument('--allow_quantity', action="store_true", default=defaults.allow_quantity,
help='Allows the use of the quantity option in yamls. Default is the set value in the host.yaml.')
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
@@ -87,7 +89,8 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
if __name__ == "__main__":
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -122,6 +125,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
player_id: int = 1
player_files: dict[int, str] = {}
player_errors: list[str] = []
allow_quantity = args.allow_quantity
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
@@ -133,7 +137,14 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
if yaml is None:
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
else:
weights_for_file.append(yaml)
quantity = yaml.get("quantity", 1)
if quantity <= 0:
raise ValueError("A quantity of 0 or less is invalid. Please change it to at least 1.")
if not allow_quantity and quantity > 1:
raise ValueError("Quantity greater than 1 is deactivated by host settings.")
for _ in range(quantity):
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e:
@@ -268,7 +279,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"(name: {args.name.get(player, name)})")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
f"File {path} document #{doc_index + 1} (with name: {args.name.get(player, name)}) is invalid. "
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
# increment for each yaml document in the file
@@ -574,7 +585,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Invalid game: {ret.game}")
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) + list(failed_world_loads.keys()),
limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
+139 -180
View File
@@ -16,131 +16,31 @@ import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from worlds.LauncherComponents import Component, Type
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
from Utils import env_cleared_lib_path, init_logging, is_linux, is_macos, is_windows, local_path
if __name__ == "__main__":
init_logging('Launcher')
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
def open_host_yaml():
s = settings.get_settings()
file = s.filename
s.save()
assert file, "host.yaml missing"
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
elif is_macos:
exe = which("open")
else:
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
for c in components:
if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) and \
(c.script_name is None or isfile(get_exe(c)[-1])):
suffixes += c.file_identifier.suffixes
try:
filename = open_filename("Select patch", (("Patches", suffixes),))
except Exception as e:
messagebox("Error", str(e), error=True)
else:
file, component = identify(filename)
if file and component:
exe = get_exe(component)
if exe is None or not isfile(exe[-1]):
exe = get_exe("Launcher")
launch([*exe, file], component.cli)
def generate_yamls(*args):
from Options import generate_yaml_templates
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
parser.add_argument("--skip_open_folder", action="store_true")
args = parser.parse_args(args)
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
if not args.skip_open_folder:
open_folder(target)
def browse_files():
open_folder(user_path())
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
elif is_macos:
exe = which("open")
else:
webbrowser.open(folder_path)
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
from settings import get_settings
get_settings().save()
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml,
description="Open the host.yaml file to change settings for generation, games, and more."),
Component("Open Patch", func=open_patch,
description="Open a patch file, downloaded from the room page or provided by the host."),
Component("Generate Template Options", func=generate_yamls,
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
])
def handle_uri(path: str) -> tuple[list[Component], Component]:
def handle_uri(path: str) -> tuple[list["Component"], "Component"]:
from worlds.LauncherComponents import components
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
client_components = []
@@ -154,7 +54,7 @@ def handle_uri(path: str) -> tuple[list[Component], Component]:
return client_components, text_client_component
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
def build_uri_popup(component_list: list["Component"], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
@@ -166,61 +66,36 @@ def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None:
return None, None
for component in components:
if component.handles_file(path):
return path, component
elif path == component.display_name or path == component.script_name:
return None, component
return None, None
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
"""Runs the given command/args in `exe` in a new process.
def get_exe(component: str | Component) -> Sequence[str] | None:
if isinstance(component, str):
name = component
component = None
if name.startswith("Archipelago"):
name = name[11:]
if name.endswith(".exe"):
name = name[:-4]
if name.endswith(".py"):
name = name[:-3]
if not name:
return None
for c in components:
if c.script_name == name or c.frozen_name == f"Archipelago{name}":
component = c
break
if not component:
return None
if is_frozen():
suffix = ".exe" if is_windows else ""
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
else:
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe, in_terminal=False):
If `in_terminal` is True, it will attempt to run in a terminal window,
and the return value will indicate whether one was found."""
if in_terminal:
if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return
return True
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm")
if terminal:
subprocess.Popen([terminal, '-e', shlex.join(exe)])
return
# Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed
ld_lib_path = os.environ.get("LD_LIBRARY_PATH")
lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else ""
env = env_cleared_lib_path()
subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env)
return True
elif is_macos:
terminal = [which('open'), '-W', '-a', 'Terminal.app']
terminal = [which("open"), "-W", "-a", "Terminal.app"]
subprocess.Popen([*terminal, *exe])
return
return True
subprocess.Popen(exe)
return False
def create_shortcut(button: Any, component: Component) -> None:
def create_shortcut(button: Any, component: "Component") -> None:
from pyshortcuts import make_shortcut
env = os.environ
if "APPIMAGE" in env:
@@ -239,11 +114,14 @@ def create_shortcut(button: Any, component: Component) -> None:
refresh_components: Callable[[], None] | None = None
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
def run_gui(launch_components: list["Component"], args: Any) -> None:
import threading
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox,
MDScreenManager, MDScreen, LoadingScreen, LogtoLoadingScreen)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
from kivy.metrics import dp
from kivy.clock import Clock
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
@@ -253,11 +131,11 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
from kivy.lang.builder import Builder
class LauncherCard(MDCard):
component: Component | None
component: "Component | None"
image: str
context_button: MDIconButton = ObjectProperty(None)
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
def __init__(self, *args, component: "Component | None" = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
@@ -270,7 +148,8 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
current_filter: Sequence[str, "Type"] | None
failed_worlds: bool = False
def __init__(self, ctx=None, components=None, args=None):
self.title = self.base_title + " " + Utils.__version__
@@ -280,6 +159,11 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
self.launch_components = components
self.launch_args = args
self.cards = []
self.current_filter = ()
super().__init__()
def load_filter(self):
from worlds.LauncherComponents import Type
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
@@ -294,7 +178,6 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
else:
filters.append(Type[filter])
self.current_filter = filters
super().__init__()
def set_favorite(self, caller):
if caller.component.display_name in self.favorites:
@@ -304,7 +187,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
self.favorites.append(caller.component.display_name)
caller.icon = "star"
def build_card(self, component: Component) -> LauncherCard:
def build_card(self, component: "Component") -> LauncherCard:
"""
Builds a card widget for a given component.
@@ -312,6 +195,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
:return: The created Card Widget.
"""
from worlds.LauncherComponents import icon_paths
button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
@@ -330,8 +214,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
return button_card
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
def _refresh_components(self, type_filter: Sequence["Type"] | None = None) -> None:
if not type_filter:
from worlds.LauncherComponents import Type
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
favorites = "favorites" in type_filter
@@ -362,6 +247,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
from worlds.LauncherComponents import Type
sub_matches = [
card for card in self.cards
@@ -372,49 +258,124 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
self.button_layout.layout.add_widget(card)
def build(self):
self.set_colors()
self.screen_manager = MDScreenManager()
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.loading_screen = LoadingScreen(name="loading")
self.screen_manager.add_widget(self.loading_screen)
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
self.search_box = self.top_screen.ids.search_box
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
main_screen = MDScreen(name="main")
main_screen.add_widget(self.top_screen)
self.screen_manager.add_widget(main_screen)
return self.screen_manager
def on_start(self):
super().on_start()
logger = logging.getLogger("Worlds")
logger.propagate = False
self.loading_handler = LogtoLoadingScreen(self.loading_screen.update_text)
logger.addHandler(self.loading_handler)
threading.Thread(target=self.do_loading, name="WorldLoading").start()
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
def do_loading(self):
import importlib
import time
start = time.perf_counter()
assert "worlds" not in sys.modules, "worlds module already loaded."
importlib.import_module("worlds")
logging.error(f"Worlds module loaded in {time.perf_counter() - start:.2f} seconds")
global refresh_components
logger = logging.getLogger("Worlds")
logger.info("User Data")
self.load_filter()
refresh_components = self._refresh_components
logger.info("Finalizing startup")
Clock.schedule_once(self.finish_loading)
def finish_loading(self, dt):
from worlds.LauncherComponents import components
from worlds import failed_world_loads
logger = logging.getLogger("Worlds")
self.failed_worlds = bool(failed_world_loads)
for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
logger.removeHandler(self.loading_handler)
self.screen_manager.current = "main"
@staticmethod
def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
open_text = "Opening in a new window..."
if button.component.func:
# Note: if we want to draw the Snackbar before running func, func needs to be wrapped in schedule_once
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
# if launch returns False, it started the process in background (not in a new terminal)
from worlds.LauncherComponents import get_exe
if not launch(get_exe(button.component), button.component.cli) and button.component.cli:
open_text = "Running in the background..."
MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
@staticmethod
def copy_to_clipboard(text):
from kivy.core.clipboard import Clipboard
Clipboard.copy(text)
MDSnackbar(MDSnackbarText(text="Copied to clipboard."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
def display_failed(self):
"""Display a dialog showing the exceptions produced by any world that failed to load during
initialization."""
if not self.failed_worlds:
return
from kivymd.uix.dialog import MDDialog, MDDialogIcon, MDDialogHeadlineText, MDDialogContentContainer
from kivymd.uix.divider import MDDivider
from worlds import failed_world_loads
from kivymd.uix.list import MDListItem, MDListItemHeadlineText, MDListItemSupportingText
entries = []
for world, reason in failed_world_loads.items():
entries.append(MDListItem(
MDListItemHeadlineText(text=world),
MDListItemSupportingText(text=reason),
on_release=lambda x, r=reason: self.copy_to_clipboard(r)
))
dialog = MDDialog(
MDDialogIcon(icon="alert"),
MDDialogHeadlineText(text="Failed World Loads"),
MDDialogContentContainer(
MDDivider(),
*entries,
orientation="vertical",
)
)
dialog.open()
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. """
from worlds.LauncherComponents import identify
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
@@ -437,6 +398,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
super()._stop(*largs)
def on_stop(self):
from worlds.LauncherComponents import Type
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
@@ -450,15 +412,11 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
refresh_components = None
def run_component(component: Component, *args):
if component.func:
component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
logging.warning(f"Component {component} does not appear to be executable.")
def run_component(component: "Component", *args):
global refresh_components
component.run(*args)
if refresh_components:
refresh_components()
def main(args: argparse.Namespace | dict | None = None):
@@ -478,6 +436,7 @@ def main(args: argparse.Namespace | dict | None = None):
else:
args['launch_components'] = [text_client_component, *components]
else:
from worlds.LauncherComponents import identify
file, component = identify(path)
if file:
args['file'] = file
+2 -2
View File
@@ -241,8 +241,8 @@ async def gba_sync_task(ctx: MMBN3Context):
await ctx.server_auth(False)
else:
if not ctx.version_warning:
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
"Please update to the latest version."
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
"Please update to the latest version. "
"Your connection to the Archipelago server will not be accepted.")
ctx.version_warning = True
except asyncio.TimeoutError:
+2
View File
@@ -207,6 +207,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
else:
logger.info("Progression balancing skipped.")
logger.info("Running pre-output steps.")
AutoWorld.call_all(multiworld, "finalize_multiworld")
AutoWorld.call_all(multiworld, "pre_output")
+4 -3
View File
@@ -38,7 +38,8 @@ class RequirementsSet(set):
local_dir = os.path.dirname(__file__)
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
core_constraints = os.path.join(local_dir, 'requirements.txt')
requirements_files = RequirementsSet((core_constraints,))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -69,7 +70,7 @@ def confirm(msg: str):
def update_command():
check_pip()
for file in requirements_files:
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--constraint", core_constraints])
def install_pkg_resources(yes=False):
@@ -79,7 +80,7 @@ def install_pkg_resources(yes=False):
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
subprocess.call([sys.executable, "-m", "pip", "install", "setuptools>=75,<81"])
def update(yes: bool = False, force: bool = False) -> None:
+18 -7
View File
@@ -21,7 +21,7 @@ import time
import typing
import weakref
import zlib
from signal import SIGINT, SIGTERM
from signal import SIGINT, SIGTERM, signal
import ModuleUpdate
@@ -2633,8 +2633,8 @@ def parse_args() -> argparse.Namespace:
goal: !remaining can be used after goal completion
''')
parser.add_argument('--auto_shutdown', default=defaults["auto_shutdown"], type=int,
help="automatically shut down the server after this many minutes without new location checks. "
"0 to keep running. Not yet implemented.")
help="automatically shut down the server after this many seconds without new location checks. "
"0 to keep running.")
parser.add_argument('--use_embedded_options', action="store_true",
help='retrieve release, remaining and hint options from the multidata file,'
' instead of host.yaml')
@@ -2742,12 +2742,23 @@ async def main(args: argparse.Namespace):
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
def stop():
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
try:
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
except NotImplementedError:
pass
ctx.commandprocessor._cmd_exit()
for signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().add_signal_handler(signal, stop)
def shutdown(signum, frame):
stop()
try:
for sig in [SIGINT, SIGTERM]:
asyncio.get_event_loop().add_signal_handler(sig, stop)
except NotImplementedError:
# add_signal_handler is only implemented for UNIX platforms
for sig in [SIGINT, SIGTERM]:
signal(sig, shutdown)
await ctx.exit_event.wait()
console_task.cancel()
+4
View File
@@ -527,7 +527,11 @@ else:
except ImportError:
pyximport = None
try:
import logging
logger = logging.getLogger()
old_level = logger.level
from _speedups import LocationStore
logger.setLevel(old_level)
except ImportError:
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
"Install a matching C++ compiler for your platform to compile _speedups.")
+56 -30
View File
@@ -212,6 +212,13 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
else:
return cls.name_lookup[value]
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, self.__class__):
return self.value == other.value
if isinstance(other, Option):
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
return self.value == other
def __int__(self) -> T:
return self.value
@@ -930,13 +937,34 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
cull_zeroes: bool = False
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
cleaned_dict = {}
invalid_value_errors = []
for key, value in value.items():
if not isinstance(value, (int, float)) or int(value) != value:
invalid_value_errors += [f"Invalid value {value} for key {key}, must be an integer."]
continue
if self.cull_zeroes and value == 0:
continue
cleaned_dict[key] = int(value)
if invalid_value_errors:
type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors
raise TypeError("\n".join(invalid_value_errors))
super(OptionCounter, self).__init__(collections.Counter(cleaned_dict))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
self.verify_values()
def verify_values(self):
range_errors = []
if self.max is not None:
@@ -959,13 +987,8 @@ class OptionCounter(OptionDict):
class ItemDict(OptionCounter):
verify_item_name = True
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
super(ItemDict, self).__init__(value)
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
cull_zeroes = True
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
@@ -1446,7 +1469,7 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict):
"""Start with the specified amount of these items. Example: "Bomb: 1" """
"""Start with the specified amount of these items. Example: {Bomb: 1, Arrow: 3} """
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
@@ -1454,7 +1477,7 @@ class StartInventory(ItemDict):
class StartInventoryPool(StartInventory):
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
"""Start with the specified amount of these items and don't place them in the world. Example: {Bomb: 1, Arrow: 3}
The game decides what the replacement items will be.
"""
@@ -1833,27 +1856,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
presets = world.web.options_presets.copy()
presets.update({"": {}})
try:
presets = world.web.options_presets.copy()
presets.update({"": {}})
option_groups = get_option_groups(world)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
option_groups = get_option_groups(world)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
except Exception as ex:
raise Exception(f"Template generation failed for world {game_name}") from ex
def dump_player_options(multiworld: MultiWorld) -> None:
+4 -3
View File
@@ -384,10 +384,11 @@ class OptionsCreator(ThemedApp):
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
text = VisualFreeText(option=option, name=name)
def set_value(instance):
self.options[name] = instance.text
def set_value(instance, value):
self.options[name] = value
text.bind(on_text_validate=set_value)
text.bind(text=set_value)
self.options[name] = option.default
return text
def create_choice(self, option: typing.Type[Choice], name: str):
+2 -1
View File
@@ -24,7 +24,6 @@ Currently, the following games are supported:
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
@@ -85,6 +84,8 @@ Currently, the following games are supported:
* APQuest
* Satisfactory
* EarthBound
* Mega Man 3
* Gauntlet Legends
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
+1 -1
View File
@@ -49,7 +49,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
if tempInstall and not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
+52 -25
View File
@@ -18,9 +18,12 @@ import logging
import warnings
from argparse import Namespace
from collections.abc import Collection, Iterable
from datetime import datetime, timezone
from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from typing import BinaryIO, Coroutine, Generic, Mapping, Optional, Set, Dict, Any, TypeVar, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
@@ -50,7 +53,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.7"
__version__ = "0.6.8"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -234,10 +237,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
subprocess.call([open_command, filename], env=env)
@@ -343,6 +343,9 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
try:
with open(path, "r") as f:
storage = unsafe_parse_yaml(f.read())
if "datapackage" in storage:
del storage["datapackage"]
logging.debug("Removed old datapackage from persistent storage")
except Exception as e:
logging.debug(f"Could not read store: {e}")
if storage is None:
@@ -367,11 +370,6 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) ->
except Exception as e:
logging.debug(f"Could not load data package: {e}")
# fall back to old cache
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
if cache.get("checksum") == checksum:
return cache
# cache does not match
return {}
@@ -453,13 +451,10 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
@@ -473,10 +468,6 @@ class RestrictedUnpickler(pickle.Unpickler):
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem":
if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module == "Options":
@@ -756,6 +747,19 @@ def is_kivy_running() -> bool:
return False
def env_cleared_lib_path() -> Mapping[str, str]:
"""
Creates a copy of the current environment vars with the LD_LIBRARY_PATH removed if set, as this can interfere when
launching something in a subprocess.
"""
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"]
return env
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
@@ -768,10 +772,7 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
@@ -1087,6 +1088,7 @@ def visualize_regions(
file_name: str,
*,
show_entrance_names: bool = False,
show_entrance_rules: bool = False,
show_locations: bool = True,
show_other_regions: bool = True,
linetype_ortho: bool = True,
@@ -1099,6 +1101,7 @@ def visualize_regions(
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
:param file_name: The name of the destination .puml file.
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
:param show_entrance_rules: (default False) If enabled, the Rule Builder explanation of the entrance's access rule will be shown near each connection.
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
Priority locations will be shown in bold.
Excluded locations will be stricken out.
@@ -1188,13 +1191,22 @@ def visualize_regions(
return re.sub("[\".:]", "", name)
def visualize_exits(region: Region) -> None:
import rule_builder.rules
for exit_ in region.exits:
color_code: str = ""
if exit_.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
if exit_.connected_region:
label = ""
if show_entrance_names:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
label += fmt(exit_)
if show_entrance_rules:
if isinstance(exit_.access_rule, rule_builder.rules.Rule.Resolved):
if label:
label += "\\n"
label += exit_.access_rule.explain_str()
if label:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{label}\"{color_code}")
else:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
@@ -1270,8 +1282,11 @@ def visualize_regions(
f.write("\n".join(uml))
class RepeatableChain:
def __init__(self, iterable: typing.Iterable):
_T_co = TypeVar("_T_co", covariant=True)
class RepeatableChain(Generic[_T_co]):
def __init__(self, iterable: Iterable[Collection[_T_co]]):
self.iterable = iterable
def __iter__(self):
@@ -1283,6 +1298,9 @@ class RepeatableChain:
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)
def __contains__(self, o: object) -> bool:
return any(o in sub_iterable for sub_iterable in self.iterable)
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
""" `str` is `Iterable`, but that's not what we want """
@@ -1291,6 +1309,15 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
return isinstance(obj, typing.Iterable)
def utcnow() -> datetime:
"""
Implementation of Python's datetime.utcnow() function for use after deprecation.
Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream).
https://ponyorm.org/ponyorm-list/2014-August/000113.html
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
+2 -1
View File
@@ -110,13 +110,14 @@ if __name__ == "__main__":
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
from worlds import AutoWorldRegister
from worlds import AutoWorldRegister, network_data_package
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
network_data_package["games"] = {k: v for k, v in network_data_package["games"].items() if k not in invalid_worlds}
create_options_files()
copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]:
+10 -1
View File
@@ -11,6 +11,7 @@ from pony.flask import Pony
from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name
from .cli import CLI
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
@@ -41,10 +42,15 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["GAME_PORTS"] = ["49152-65535", 0]
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# maximum time in seconds since last activity for a room to be hosted
app.config["MAX_ROOM_TIMEOUT"] = 259200
# minimum time in days since last activity for a room to be deleted. 0 to disable.
app.config["ROOM_AUTO_DELETE"] = 0
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
@@ -64,10 +70,13 @@ app.config["ASSET_RIGHTS"] = False
cache = Cache()
Compress(app)
CLI(app)
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
if "=" in value or any(c.isspace() for c in value):
raise ValueError("Invalid UUID format")
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)))
def to_url(value: uuid.UUID) -> str:
+16 -9
View File
@@ -4,14 +4,14 @@ import json
import logging
import multiprocessing
import typing
from datetime import timedelta, datetime
from datetime import timedelta
from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit, PrimaryKey
from pony.orm import db_session, select, commit, PrimaryKey, desc
from Utils import restricted_loads
from Utils import restricted_loads, utcnow
from .locker import Locker, AlreadyRunningException
_stop_event = Event()
@@ -100,13 +100,18 @@ def init_generator(config: dict[str, Any]) -> None:
db.generate_mapping()
def cleanup():
"""delete unowned user-content"""
def cleanup(config: dict[str, Any]):
"""delete unowned or old user-content"""
auto_delete: int = config.get("ROOM_AUTO_DELETE", 0)
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
if auto_delete > 0:
cutoff = utcnow() - timedelta(days=auto_delete)
rooms += Room.select(lambda room: room.last_activity < cutoff).delete(bulk=True)
seeds += Seed.select(lambda seed: not seed.rooms and seed.creation_time < cutoff).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
@@ -118,7 +123,7 @@ def autohost(config: dict):
stop_event = _stop_event
try:
with Locker("autohost"):
cleanup()
cleanup(config)
hosters = []
for x in range(config["HOSTERS"]):
hoster = MultiworldInstance(config, x)
@@ -129,10 +134,11 @@ def autohost(config: dict):
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3))
room.last_activity >= utcnow() - timedelta(
seconds=config["MAX_ROOM_TIMEOUT"])).order_by(desc(Room.last_port))
for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException:
@@ -187,6 +193,7 @@ class MultiworldInstance():
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
self.game_ports = config["GAME_PORTS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
@@ -197,7 +204,7 @@ class MultiworldInstance():
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host,
self.cert, self.key, self.host, self.game_ports,
self.rooms_to_start, self.rooms_shutting_down),
name=self.name)
process.start()
+8
View File
@@ -0,0 +1,8 @@
from flask import Flask
class CLI:
def __init__(self, app: Flask) -> None:
from .stats import stats_cli
app.cli.add_command(stats_cli)
+36
View File
@@ -0,0 +1,36 @@
import click
from flask.cli import AppGroup
from pony.orm import raw_sql
from Utils import format_SI_prefix
stats_cli = AppGroup("stats")
@stats_cli.command("show")
def show() -> None:
from pony.orm import db_session, select
from WebHostLib.models import GameDataPackage
total_games_package_count: int = 0
total_games_package_size: int
top_10_package_sizes: list[tuple[int, str]] = []
with db_session:
data_length = raw_sql("LENGTH(data)")
data_length_desc = raw_sql("LENGTH(data) DESC")
data_length_sum = raw_sql("SUM(LENGTH(data))")
total_games_package_count = GameDataPackage.select().count()
total_games_package_size = select(data_length_sum for _ in GameDataPackage).first() # type: ignore
top_10_package_sizes = list(
select((data_length, dp.checksum) for dp in GameDataPackage) # type: ignore
.order_by(lambda _, _2: data_length_desc)
.limit(10)
)
click.echo(f"Total number of games packages: {total_games_package_count}")
click.echo(f"Total size of games packages: {format_SI_prefix(total_games_package_size, power=1024)}B")
click.echo(f"Top {len(top_10_package_sizes)} biggest games packages:")
for size, checksum in top_10_package_sizes:
click.echo(f" {checksum}: {size:>8d}")
+118 -22
View File
@@ -4,6 +4,7 @@ import asyncio
import collections
import datetime
import functools
import itertools
import logging
import multiprocessing
import pickle
@@ -13,7 +14,9 @@ import threading
import time
import typing
import sys
from collections.abc import Iterable
import psutil
import websockets
from pony.orm import commit, db_session, select
@@ -24,6 +27,7 @@ from MultiServer import (
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db
@@ -76,12 +80,8 @@ class WebHostContext(Context):
self.tags = ["AP", "WebHost"]
def __del__(self):
try:
import psutil
from Utils import format_SI_prefix
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
except ImportError:
self.logger.debug("Context destroyed")
from Utils import format_SI_prefix
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
def _load_game_data(self):
for key, value in self.static_server_data.items():
@@ -115,7 +115,7 @@ class WebHostContext(Context):
if room.last_port:
self.port = room.last_port
else:
self.port = get_random_port()
self.port = 0
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
@@ -172,7 +172,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.datetime.utcnow()
room.last_activity = Utils.utcnow()
return True
def get_save(self) -> dict:
@@ -181,8 +181,97 @@ class WebHostContext(Context):
return d
def get_random_port():
return random.randint(49152, 65535)
class GameRangePorts(typing.NamedTuple):
valid_ports: list[int]
ephemeral_allowed: bool
class RandomPortSocketCreator:
""" Creates server sockets on random available ports from a configured range. """
_next_port_index: int
_used_ports_cache: tuple[frozenset[int], int] | None
_parsed_ports: GameRangePorts
def __init__(self, game_ports: Iterable[str | int]) -> None:
self._next_port_index = 0
self._used_ports_cache = None
self._parsed_ports = self._parse_game_ports(game_ports)
@staticmethod
def _parse_game_ports(game_ports: Iterable[str | int]) -> GameRangePorts:
""" Parse the game ports configuration into a structured format. """
valid_ports: list[int] = []
ephemeral_allowed = False
for item in game_ports:
if isinstance(item, str) and "-" in item:
start, end = map(int, item.split("-"))
x = range(start, end + 1)
valid_ports.extend(x)
elif int(item) == 0:
ephemeral_allowed = True
else:
valid_ports.append(int(item))
random.shuffle(valid_ports)
return GameRangePorts(valid_ports, ephemeral_allowed)
@staticmethod
def _try_conns_per_process(p: psutil.Process) -> Iterable[int]:
""" Get ports from a single process's connections. """
try:
return (c.laddr.port for c in p.net_connections("tcp4") if c.laddr)
except psutil.AccessDenied:
return ()
@staticmethod
def _get_active_net_connections() -> Iterable[int]:
""" Get all active TCP4 connections on the system. """
# Don't even try to check if system using AIX
if psutil.AIX:
return ()
try:
return (c.laddr.port for c in psutil.net_connections("tcp4") if c.laddr)
# raises AccessDenied when done on macOS
except psutil.AccessDenied:
# flatten the list of iterables
return itertools.chain.from_iterable(map(
RandomPortSocketCreator._try_conns_per_process,
psutil.process_iter(["net_connections"])
))
def _get_used_ports(self) -> frozenset[int]:
""" Get currently used ports with 90-second caching. """
t_hash = round(time.monotonic() / 90)
if self._used_ports_cache is None or self._used_ports_cache[1] != t_hash:
self._used_ports_cache = (frozenset(self._get_active_net_connections()), t_hash)
return self._used_ports_cache[0]
def create(self, host: str) -> socket.socket:
""" Create a server socket on an available port. """
valid_ports, ephemeral_allowed = self._parsed_ports
used_ports = self._get_used_ports()
next_index = self._next_port_index
for i, port in enumerate(itertools.chain(valid_ports[next_index:], valid_ports[:next_index])):
if port in used_ports:
continue
try:
res = socket.create_server((host, port))
next_index = (next_index + i + 1) % len(valid_ports)
self._next_port_index = next_index
return res
except OSError:
pass
if ephemeral_allowed:
return socket.create_server((host, 0))
raise OSError(98, "No available ports")
@cache_argsless
@@ -247,7 +336,8 @@ def tear_down_logging(room_id):
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
host: str, game_ports: Iterable[str | int],
rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
@@ -291,6 +381,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop()
socket_creator = RandomPortSocketCreator(game_ports)
async def start_room(room_id):
with Locker(f"RoomLocker {room_id}"):
@@ -300,20 +391,26 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.load(room_id)
ctx.init_save()
assert ctx.server is None
try:
if ctx.port != 0:
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
except OSError:
ctx.port = 0
if ctx.port == 0:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
sock=socket_creator.create(ctx.host),
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
@@ -367,8 +464,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.")
@@ -389,7 +485,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
+4 -3
View File
@@ -1,8 +1,9 @@
from datetime import timedelta, datetime
from datetime import timedelta
from flask import render_template
from pony.orm import count
from Utils import utcnow
from WebHostLib import app, cache
from .models import Room, Seed
@@ -10,6 +11,6 @@ from .models import Room, Seed
@app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
def landing():
rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7))
seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7))
rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7))
seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7))
return render_template("landing.html", rooms=rooms, seeds=seeds)
+7 -5
View File
@@ -9,11 +9,12 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
from Utils import title_sorted, utcnow
class WebWorldTheme(StrEnum):
DIRT = "dirt"
@@ -233,11 +234,12 @@ def host_room(room: UUID):
if room is None:
return abort(404)
now = datetime.datetime.utcnow()
now = utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
should_refresh = (
(not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)
)
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
+5 -3
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
from Utils import utcnow
db = Database()
STATE_QUEUED = 0
@@ -20,8 +22,8 @@ class Slot(db.Entity):
class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True)
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
owner = Required(UUID, index=True)
commands = Set('Command')
seed = Required('Seed', index=True)
@@ -38,7 +40,7 @@ class Seed(db.Entity):
rooms = Set(Room)
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
+13 -12
View File
@@ -1,14 +1,15 @@
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
flask==3.1.3
werkzeug==3.1.6
pony==0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2
Flask-Caching>=2.3.0
waitress==3.0.2
Flask-Caching==2.3.1
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12
Flask-Cors>=6.0.2
bokeh>=3.6.3
markupsafe>=3.0.2
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2
Flask-Limiter==4.1.1
Flask-Cors==6.0.2
bokeh==3.8.2
markupsafe==3.0.3
setproctitle==1.3.7
mistune==3.3.0
docutils==0.22.4
psutil==7.2.2
+18 -4
View File
@@ -123,12 +123,26 @@ window.addEventListener('load', () => {
});
const addRangeRow = (optionName) => {
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
const inputQuery = `input[data-option="${optionName}"]`;
const inputTarget = document.querySelector(inputQuery);
const newValue = inputTarget.value;
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
switch (inputTarget.type) {
case 'number':
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
}
break;
case 'text':
if (newValue === "") {
alert('Range values for text must be a non-empty string!');
return;
}
break;
default:
console.error(`Found unsupported input type: ${inputTarget.type}`);
return;
break;
}
inputTarget.value = '';
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
@@ -42,3 +42,7 @@
#games .page-controls button{
margin-left: 0.5rem;
}
#games .author-label{
margin-top: 0.5rem;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{% block footer %}
<footer id="island-footer">
<div id="copyright-notice">Copyright 2025 Archipelago</div>
<div id="copyright-notice">Copyright 2026 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-
+10 -1
View File
@@ -33,7 +33,9 @@
<h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
custom worlds</a> section of the setup guide and the
<a href="{{ url_for("tutorial", game="Archipelago", file="other_en") }}">other games and tools guide</a>
to find more.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">
@@ -47,6 +49,13 @@
<details data-game="{{ game_name }}">
<summary class="h2">{{ game_name }}</summary>
{{ world.__doc__ | default("No description provided.", true) }}<br />
{% if "authors" in world.manifest %}
{% if world.manifest["authors"]|length == 1 %}
<p class="author-label">Author: {{ world.manifest["authors"][0] }}</p>
{% else %}
<p class="author-label">Authors: {{ world.manifest["authors"] | join(", ") }}</p>
{% endif %}
{% endif %}
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span>
+1 -5
View File
@@ -20,11 +20,7 @@
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
by {{ file_data.authors | join(", ") }}
</li>
{% endfor %}
</ul>
@@ -71,10 +71,10 @@
<div class="hint-text">
This option allows custom values only. Please enter your desired values below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" data-option="{{ option_name }}">Add</button>
<input type="text" class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div>
<table>
<table class="range-rows" data-option="{{ option_name }}">
<tbody>
{% if option.default %}
{{ RangeRow(option_name, option, option.default, option.default) }}
@@ -88,11 +88,11 @@
<div class="hint-text">
Custom values are also allowed for this option. To create one, enter it into the input box below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" data-option="{{ option_name }}">Add</button>
<input type="text" class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div>
</div>
<table>
<table class="range-rows" data-option="{{ option_name }}">
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
+4 -3
View File
@@ -10,7 +10,7 @@ from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second
from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType
from Utils import restricted_loads, KeyedDefaultDict
from Utils import restricted_loads, KeyedDefaultDict, utcnow
from . import app, cache
from .models import GameDataPackage, Room
@@ -273,9 +273,10 @@ class TrackerData:
Does not include players who have no activity recorded.
"""
last_activity: Dict[TeamPlayer, datetime.timedelta] = {}
now = datetime.datetime.utcnow()
now = utcnow()
for (team, player), timestamp in self._multisave.get("client_activity_timers", []):
last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
from_timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).replace(tzinfo=None)
last_activity[team, player] = now - from_timestamp
return last_activity
+31
View File
@@ -0,0 +1,31 @@
import os
def pytest_configure(config):
# AP_TEST_WORLDS implies `-m world` unless an explicit -m was given; the scoping itself lives in
# worlds/__init__
if os.environ.get("AP_TEST_WORLDS") and not config.option.markexpr:
config.option.markexpr = "world"
def pytest_ignore_collect(collection_path, config):
# skip worlds/<world>/... for any world not named, so other worlds are never imported
env = os.environ.get("AP_TEST_WORLDS")
if not env:
return None
selected = {name.strip() for name in env.split(",") if name.strip()}
parts = collection_path.parts
if "worlds" in parts:
i = parts.index("worlds")
if i + 1 < len(parts) and parts[i + 1] not in selected:
return True
return None
def pytest_collection_modifyitems(items):
# mark for `-m world`: classes with `world_relevant = True`, plus anything under worlds/ (nodeid is
# always "/"-separated and relative to rootdir)
for item in items:
if getattr(getattr(item, "cls", None), "world_relevant", False) or \
item.nodeid.split("/", 1)[0] == "worlds":
item.add_marker("world")
+36 -6
View File
@@ -1,3 +1,4 @@
#:import Utils Utils
<LauncherCard>:
id: main
style: "filled"
@@ -61,7 +62,6 @@
text: "Open"
#:import Type worlds.LauncherComponents.Type
MDFloatLayout:
id: top_screen
@@ -79,7 +79,7 @@ MDFloatLayout:
MDButton:
id: all
style: "text"
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
type: ("CLIENT", "TOOL", "ADJUSTER", "MISC")
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -89,7 +89,7 @@ MDFloatLayout:
MDButton:
id: client
style: "text"
type: (Type.CLIENT, )
type: ("CLIENT", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -99,7 +99,7 @@ MDFloatLayout:
MDButton:
id: Tool
style: "text"
type: (Type.TOOL, )
type: ("TOOL", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -109,7 +109,7 @@ MDFloatLayout:
MDButton:
id: adjuster
style: "text"
type: (Type.ADJUSTER, )
type: ("ADJUSTER", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -119,7 +119,7 @@ MDFloatLayout:
MDButton:
id: misc
style: "text"
type: (Type.MISC, )
type: ("MISC", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
@@ -140,6 +140,15 @@ MDFloatLayout:
MDNavigationDrawerDivider:
MDBoxLayout:
orientation: "horizontal"
MDIconButton:
icon: "alert" if app.failed_worlds else ""
theme_text_color: "Custom"
text_color: "D23C42"
disabled: not app.failed_worlds
on_release: app.display_failed()
MDGridLayout:
id: main_layout
@@ -159,3 +168,24 @@ MDFloatLayout:
ScrollBox:
id: button_layout
<LoadingScreen>:
label: label
md_bg_color: self.theme_cls.backgroundColor
ApAsyncImage:
source: Utils.local_path("data/icon.png")
pos_hint: {"center_x": 0.5, "center_y": 0.5}
size_hint: None, None
size: 512, 512
opacity: 0.5
MDCircularProgressIndicator:
pos_hint: {"center_x": 0.5, "center_y": 0.5}
size: 550, 550
size_hint: None, None
MDLabel:
id: label
text: "Loading..."
halign: "center"
pos_hint: {"center_x": 0.5, "center_y": 0.5}
theme_text_color: "Primary"
+11 -9
View File
@@ -41,16 +41,8 @@ http {
# server_name example.com www.example.com;
keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
@@ -60,5 +52,15 @@ http {
proxy_pass http://app_server;
}
location /static/ {
root /app/WebHostLib/;
autoindex off;
}
location = /favicon.ico {
alias /app/WebHostLib/static/static/favicon.ico;
access_log off;
}
}
}
+6 -5
View File
@@ -19,8 +19,6 @@
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria
/worlds/aquaria/ @tioui
@@ -58,9 +56,6 @@
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
# Donkey Kong Country 3
/worlds/dkc3/ @PoryGone
# DLCQuest
/worlds/dlcquest/ @axe-y @agilbert1412
@@ -85,6 +80,9 @@
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
# Gauntlet Legends
/worlds/gl/ @jamesbrq
# Heretic
/worlds/heretic/ @Daivuk @KScl
@@ -134,6 +132,9 @@
# Mega Man 2
/worlds/mm2/ @Silvris
# Mega Man 3
/worlds/mm3/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic
+5 -3
View File
@@ -87,12 +87,14 @@ The world is your game integration for the Archipelago generator, webhost, and m
information necessary for creating the items and locations to be randomized, the logic for item placement, the
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
repository and creating a new world package in `/worlds/`.
repository and creating a new world package in `/worlds/` (see [running from source](/docs/running%20from%20source.md)
for setup).
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
check out [world maintainer.md](/docs/world%20maintainer.md).
regarding the API can be found in the [world api doc](/docs/world%20api.md), and the [APQuest](/worlds/apquest/) world
is a complete world implementation that functions as an introduction to world development. Before publishing, make sure
to also check out [world maintainer.md](/docs/world%20maintainer.md).
### Hard Requirements
+24 -4
View File
@@ -35,8 +35,8 @@ There are also the following optional fields:
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An APWorld without a world_version is always treated as older than one with a version
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
package managers. Should always be a list of strings.
* `authors` - a list of authors of the world. Displayed in user-facing places like the Supported Games page
on WebHost. Should always be a list of strings.
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
@@ -44,10 +44,30 @@ These get automatically added to the `archipelago.json` of an .apworld if it is
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### Choosing `minimum_ap_version` and `maximum_ap_version`
Both fields are optional, and most worlds only ever need `minimum_ap_version`.
* **`minimum_ap_version`** - the most cost-effective approach is to set it to the latest stable Archipelago
version when you first create your world, then only raise it when you deliberately start using a new core
feature that requires a newer version. When you want such a feature you can choose to either bump
`minimum_ap_version`, write code that supports both the old and new core conditionally (for example a
`try`/`except` around a moved import), or decide the feature is not worth it and leave it alone. There is
usually no need to determine your world's "true" minimum version, since most players run the latest release
or close to it.
* **`maximum_ap_version`** - rarely needed. Only set it when you already know a particular Archipelago version
breaks your world and you cannot quickly fix it or handle the difference conditionally. Most incompatibilities
are better resolved by updating the world instead. The main legitimate use case is a world or tool that is
tightly coupled to core's generation behavior, where supporting both sides of a breaking change in a single
release is not feasible.
When present, both fields use the same `"major.minor.build"` string format as the Archipelago version itself,
for example `"0.6.4"`.
### "Build APWorlds" Launcher Component
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
component that will package all world folders to `.apworld`, and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
-9
View File
@@ -77,15 +77,6 @@ Changes made to `docker-compose.yaml` can be applied by running `docker compose
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
Enemizer is not currently available for `aarch64`.
## Optional: Git
Building the image requires a local copy of the ArchipelagoMW source code.
-6
View File
@@ -69,12 +69,6 @@ flowchart LR
end
SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Super Mario World
subgraph Super Mario World
SMW[SNES]
+194 -57
View File
@@ -1,6 +1,7 @@
# Rule Builder
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface to define rules and the following advantages:
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface
to define rules and the following advantages:
- Rule classes that avoid all the common pitfalls
- Logic optimization
@@ -12,13 +13,21 @@ This document describes the API provided for the rule builder. Using this API pr
The rule builder consists of 3 main parts:
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic. They can be combined and take into account your world's options. There are a number of default rules listed below, and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance they must be resolved.
1. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly creating these but they'll be created when assigning rules to locations or entrances. These are what power the human-readable logic explanations.
1. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic.
They can be combined and take into account your world's options. There are a number of default rules listed below,
and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance
they must be resolved.
2. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules
specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly
creating these but they'll be created when assigning rules to locations or entrances. These are what power the
human-readable logic explanations.
3. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from
instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
## Usage
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule
objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
```python
# In your world's create_regions method
@@ -32,6 +41,7 @@ The rule builder comes with a number of rules by default:
- `False_`: Always returns false
- `And`: Checks that all child rules are true (also provided by `&` operator)
- `Or`: Checks that at least one child rule is true (also provided by `|` operator)
- `AtLeast`: Checks that at least some count of rules is true
- `Has`: Checks that the player has the given item with the given count (default 1)
- `HasAll`: Checks that the player has all given items
- `HasAny`: Checks that the player has at least one of the given items
@@ -40,18 +50,22 @@ The rule builder comes with a number of rules by default:
- `HasFromList`: Checks that the player has some number of given items
- `HasFromListUnique`: Checks that the player has some number of given items, ignoring duplicates of the same item
- `HasGroup`: Checks that the player has some number of items from a given item group
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the same item
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the
same item
- `CanReachLocation`: Checks that the player can logically reach the given location
- `CanReachRegion`: Checks that the player can logically reach the given region
- `CanReachEntrance`: Checks that the player can logically reach the given entrance
You can combine these rules together to describe the logic required for something. For example, to check if a player either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
You can combine these rules together to describe the logic required for something. For example, to check if a player
either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
```python
rule = Has("Movement ability") | HasAll("Key 1", "Key 2")
```
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check if a rule is defined you must use `if rule is not None`.
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In
> order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check
> if a rule is defined you must use `if rule is not None`.
### Assigning rules
@@ -61,13 +75,16 @@ When assigning the rule you must use the `set_rule` helper to correctly resolve
self.set_rule(location_or_entrance, rule)
```
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify `force_creation=True` if you would like to create the entrance even if the rule is `False`.
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the
entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify
`force_creation=True` if you would like to create the entrance even if the rule is `False`.
```python
self.create_entrance(from_region, to_region, rule)
```
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify
> the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
You can also set a rule for your world's completion condition:
@@ -77,21 +94,42 @@ self.set_completion_rule(rule)
### Restricting options
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an iterable of `OptionFilter` instances. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an
iterable of `OptionFilter` instances. When resolved, if no filters are provided or all of them pass then the rule will
resolve as normal. Otherwise, the rule will be replaced with `True` or `False` depending on what `filtered_resolution`
is set to, which defaults to `False`.
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
```python
rule1 = Has(
"Fast Travel Spell",
options=[OptionFilter(RandoFastTravel, RandoFastTravel.option_true)],
)
rule2 = Has(
"Starting Party Member",
options=[OptionFilter(RandoParty, 1)], # option attributes are suggested but any value works
filtered_resolution=True,
)
```
- `eq`: `==`
- `ne`: `!=`
- `gt`: `>`
- `lt`: `<`
- `ge`: `>=`
- `le`: `<=`
- `contains`: `in`
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are
allowed:
By default rules that are excluded by their options will default to `False`. If you want to default to `True` instead, you can specify `filtered_resolution=True` on your rule.
- `eq`: `option_value == filter_value`
- `ne`: `option_value != filter_value`
- `gt`: `option_value > filter_value`
- `lt`: `option_value < filter_value`
- `ge`: `option_value >= filter_value`
- `le`: `option_value <= filter_value`
- `in`: `option_value in filter_value`
- `contains`: `filter_value in option_value` (note reversed operands)
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
```python
rule1 = Has("Movement Ability", options=[OptionFilter(SkipsLevel, SkipsLevel.option_hard, operator="lt")])
rule2 = Has("Item", options=[OptionFilter(ChoiceOption, [1, 5], operator="in")])
```
To check if the player has received the switch item if switches are randomized, or if they can reach the switch when not
randomized:
```python
rule = (
@@ -115,12 +153,12 @@ If you would like to provide option filters when reusing or composing rules, you
common_rule = Has("A") | HasAny("B", "C")
...
rule = (
Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
Filtered(common_rule, options=[OptionFilter(Opt, 0)])
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)])
)
```
You can also use the & and | operators to apply options to rules:
For convenience, you can also use the `&` and `|` operators to apply options to rules:
```python
common_rule = Has("A")
@@ -129,22 +167,73 @@ common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
Combining the above, you can easily bypass a requirement based on option choices:
```python
rule = Has("Some Upgrade") | OptionFilter(CombatDifficulty, CombatDifficulty.option_medium, operator="ge")
```
### Field resolvers
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a
`FieldResolver` to define how to populate that field when the rule is being resolved.
There are two build-in field resolvers:
- `FromOption`: Resolves to the value of the given option
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get
a nested attribute or dict item
```python
world.options.mcguffin_count = 5
world.precalculated_value = 99
rule = (
Has("A", count=FromOption(McguffinCount))
| HasGroup("Important items", count=FromWorldAttr("precalculated_value"))
)
# Results in Has("A", count=5) | HasGroup("Important items", count=99)
```
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and
implements a `resolve` function:
```python
@dataclasses.dataclass(frozen=True)
class FromCustomResolution(FieldResolver, game="MyGame"):
modifier: str
@override
def resolve(self, world: "World") -> Any:
return some_math_calculation(world, self.modifier)
rule = Has("Combat Level", count=FromCustomResolution("combat"))
```
If you want to support rule serialization and your resolver contains non-serializable properties you may need to
override `to_dict` or `from_dict`.
## Enabling caching
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your
rules.
```python
class MyWorld(CachedRuleBuilderWorld):
game = "My Game"
```
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead
cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
### Item name mapping
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps actual item names to real item names so the cache system knows what to invalidate.
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps
actual item names to real item names so the cache system knows what to invalidate.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical
`Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical
`Currency`.
```python
class MyWorld(CachedRuleBuilderWorld):
@@ -158,9 +247,13 @@ class MyWorld(CachedRuleBuilderWorld):
## Defining custom rules
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide
the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and
to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more dependencies functions as outlined below.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically
be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more
dependencies functions as outlined below.
To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement:
@@ -209,7 +302,10 @@ class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
### Item dependencies
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this function even when caching is disabled as more things may use it in the future.
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of
your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id
of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this
function even when caching is disabled as more things may use it in the future.
```python
@dataclasses.dataclass()
@@ -226,7 +322,10 @@ All of the default `Has*` rules define this function already.
### Region dependencies
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of
region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -243,7 +342,10 @@ The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules d
### Location dependencies
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping
of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -260,7 +362,10 @@ The default `CanReachLocation` rule defines this function already.
### Entrance dependencies
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping
of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -277,9 +382,13 @@ The default `CanReachEntrance` rule defines this function already.
### Rule explanations
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally
accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will
display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is
useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class:
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your
`Resolved` class:
```python
class MyRule(Rule, game="My Game"):
@@ -316,22 +425,35 @@ class MyRule(Rule, game="My Game"):
### Cache control
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two class attributes on the `Resolved` class you can override to change this behavior.
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two
class attributes on the `Resolved` class you can override to change this behavior.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the overhead of the caching system will slow it down.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and
always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will
cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your
rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when
being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still
define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the
overhead of the caching system will slow it down.
### Caveats
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if your world has opted into caching.
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if
your world has opted into caching.
- Resolved rules are forced to be frozen dataclasses. They and all their attributes must be immutable and hashable.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved`
instances directly.
## Serialization
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the
rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and
entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule would look like:
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and
an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule
would look like:
```python
{
@@ -344,7 +466,8 @@ The dict contains a `rule` key with the name of the rule, an `options` key with
}
```
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in the same serializable format:
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in
the same serializable format:
```python
{
@@ -428,7 +551,8 @@ class BasicLogicRule(Rule, game="My Game"):
}
```
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it correctly:
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it
correctly:
```python
class BasicLogicRule(Rule, game="My Game"):
@@ -449,10 +573,14 @@ These are properties and helpers that are available to you in your world.
#### Methods
- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the
inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given
location or entrance
- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` unless force_creation is `True`
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`:
Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates
to `False_()` unless force_creation is `True`
#### CachedRuleBuilderWorld Properties
@@ -465,18 +593,27 @@ The following property is only available when inheriting from `CachedRuleBuilder
These are properties and helpers that you can use or override for custom rules.
- `_instantiate(world: World)`: Create a new resolved rule instance, override for custom rules as required
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if you've overridden `to_dict`
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's
serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if
you've overridden `to_dict`
- `__str__()`: Basic string representation of a rule, useful for debugging
#### Resolved rule API
- `player: int`: The slot this rule is resolved for
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for
this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item
collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching
regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on
reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on
reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic
(and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is
defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules
-10
View File
@@ -78,16 +78,6 @@ first generate the binary distribution and then run `python setup.py bdist_appim
put an `appimagetool` into the directory you run the command from, rename it to `appimagetool` and make it executable.
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
You can get the latest Enemizer release at [Enemizer Github releases](https://github.com/Ijwu/Enemizer/releases).
It should be dropped as "EnemizerCLI" into the root folder of the project. Alternatively, you can point the Enemizer
setting in host.yaml at your Enemizer executable.
## Optional: SNI
[SNI](https://github.com/alttpo/sni/blob/main/README.md) is required to use SNIClient. If not integrated into the project, it has to be started manually.
+17 -1
View File
@@ -131,16 +131,32 @@ Unless you configured PyCharm to use pytest as a test runner, you may get import
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the
Your working directory should be the root Archipelago directory and the script should be the
tests folder within your world.
You can also find the 'Archipelago Unittests' as an option in the dropdown at the top of the window
next to the run and debug buttons.
To run the suite scoped to a single world, use the shared **APQuest Tests** run configuration in the dropdown.
To test your own world, duplicate it in *Edit Configurations…* and change the `AP_TEST_WORLDS` environment
variable to your world's folder name.
#### Running Tests without Pycharm
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
#### Running Tests for Specific Worlds
Set the `AP_TEST_WORLDS` environment variable to a comma-separated list of world **folder** names to scope a run
to just those worlds:
```
AP_TEST_WORLDS=apquest pytest
```
Pass several worlds with a comma, e.g. `AP_TEST_WORLDS=apquest,pokemon_emerald`. Add
`--continue-on-collection-errors` if your environment is missing the webhost requirements (`flask`, etc.).
#### Running Tests Multithreaded
pytest can run multiple test runners in parallel with the pytest-xdist extension.
-1
View File
@@ -108,7 +108,6 @@ Example:
```json
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
...
}
+6
View File
@@ -17,6 +17,12 @@
# Web hosting port
#PORT: 80
# Ports used for game hosting. Values can be specific ports, port ranges or both. Default is: [49152-65535, 0]
# Zero means it will use a random free port if there is no free port in the ranges specified
# Examples of valid values: [40000-41000, 49152-65535]
# If ports within the range(s) are already in use, the WebHost will fallback to the default [49152-65535, 0] range.
#GAME_PORTS: [49152-65535, 0]
# Place where uploads go.
#UPLOAD_FOLDER: uploads
+9 -3
View File
@@ -327,6 +327,11 @@ reject the placement of an item there.
### Events (or "generation-only items/locations")
> **Warning:** If you're trying to tell the Archipelago server that the player has achieved their goal, you want to send
a [StatusUpdate packet](network%20protocol.md#statusupdate), or however [your client library](network%20protocol.md)
wraps it. Despite the popularity of "victory events" during generation, events have nothing to do with how goals are
triggered during gameplay.
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
@@ -491,9 +496,10 @@ class MyGameWorld(World):
base_id = 1234
# instead of dynamic numbering, IDs could be part of data
# The following two dicts are required for the generation to know which
# items exist. They could be generated from json or something else. They can
# include events, but don't have to since events will be placed manually.
# The following two dicts are required for the generation to know which items exist.
# They can be generated with arbitrary code during world load, but keep in mind that
# anything expensive (e.g. parsing non-python data files) will delay world loading.
# They can include events, but don't have to since events will be placed manually.
item_name_to_id = {name: id for
id, name in enumerate(mygame_items, base_id)}
location_name_to_id = {name: id for
+14 -3
View File
@@ -186,9 +186,20 @@ class ERPlacementState:
self.pairings = []
self.world = world
self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup
# Construct an 'all state', similar to MultiWorld.get_all_state(), but only for the world which is having its
# entrances randomized.
single_player_all_state = CollectionState(world.multiworld, True)
player = world.player
for item in world.multiworld.itempool:
if item.player == player:
world.collect(single_player_all_state, item)
for item in world.get_pre_fill_items():
world.collect(single_player_all_state, item)
single_player_all_state.sweep_for_advancements(world.get_locations())
self.collection_state = single_player_all_state
@property
def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player]
@@ -226,7 +237,7 @@ class ERPlacementState:
copied_state.blocked_connections[self.world.player].remove(source_exit)
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
copied_state.update_reachable_regions(self.world.player)
copied_state.sweep_for_advancements()
copied_state.sweep_for_advancements(self.world.get_locations())
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
available_randomized_exits = copied_state.blocked_connections[self.world.player]
for _exit in available_randomized_exits:
@@ -402,7 +413,7 @@ def randomize_entrances(
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
# propagate new connections
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
er_state.collection_state.sweep_for_advancements(world.get_locations())
if on_connect:
change = on_connect(er_state, placed_exits, paired_entrances)
if change:
+12 -8
View File
@@ -57,9 +57,8 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
[Files]
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs;
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs;
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
@@ -83,7 +82,6 @@ Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
[Registry]
@@ -98,11 +96,6 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
@@ -213,6 +206,17 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apgl"; ValueData: "{#MyAppName}glpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}glpatch"; ValueData: "Archipelago Gauntlet Legends Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}glpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}glpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
+26 -9
View File
@@ -122,6 +122,22 @@ class ThemedApp(MDApp):
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
class LogtoLoadingScreen(logging.Handler):
def __init__(self, on_log):
super().__init__()
self.on_log = on_log
def handle(self, record: logging.LogRecord):
self.on_log(record.getMessage())
class LoadingScreen(MDScreen):
label = ObjectProperty(None)
def update_text(self, text):
self.label.text = text
class ImageIcon(MDButtonIcon, AsyncImage):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -363,15 +379,16 @@ class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
text += "\nPermissions:"
for permission_name, permission_data in ctx.permissions.items():
text += f"\n {permission_name}: {permission_data}"
if ctx.hint_cost is not None and ctx.total_locations:
min_cost = int(ctx.server_version >= (0, 3, 9))
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
f"For you this means every " \
f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
"location checks." \
f"\nYou currently have {ctx.hint_points} points."
elif ctx.hint_cost == 0:
text += "\n!hint is free to use."
if ctx.total_locations and ctx.hint_cost is not None:
if ctx.hint_cost == 0:
text += "\n!hint is free to use."
else:
min_cost = int(ctx.server_version >= (0, 3, 9))
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
f"For you this means every " \
f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
"location checks." \
f"\nYou currently have {ctx.hint_points} points."
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
text += "\nRace mode is enabled." \
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
+2
View File
@@ -5,3 +5,5 @@ python_functions = test
testpaths =
test
worlds
markers =
world: general tests that iterate over every registered world (scope with AP_TEST_WORLDS)
+17 -17
View File
@@ -1,21 +1,21 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.3
jellyfish>=1.2.1
jinja2>=3.1.6
schema>=0.7.8
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.5.0
certifi>=2025.11.12
cython>=3.2.1
cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
pathspec>=0.12.1
colorama==0.4.6
websockets==13.1 # ,<14
PyYAML==6.0.3
jellyfish==1.2.1
jinja2==3.1.6
schema==0.7.8
kivy==2.3.1
bsdiff4==1.2.6
platformdirs==4.9.4
certifi==2026.2.25
cython==3.2.4
cymem==2.0.13
orjson==3.11.7
typing_extensions==4.15.0
pyshortcuts==1.9.7
pathspec==1.0.4
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on
Pymem>=1.13.0
Pymem==1.14.0
+162
View File
@@ -0,0 +1,162 @@
import dataclasses
import importlib
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast, overload
from typing_extensions import override
from Options import Option
if TYPE_CHECKING:
from worlds.AutoWorld import World
class FieldResolverRegister:
"""A container class to contain world custom resolvers"""
custom_resolvers: ClassVar[dict[str, dict[str, type["FieldResolver"]]]] = {}
"""
A mapping of game name to mapping of resolver name to resolver class
to hold custom resolvers implemented by worlds
"""
@classmethod
def get_resolver_cls(cls, game_name: str, resolver_name: str) -> type["FieldResolver"]:
"""Returns the world-registered or default resolver with the given name"""
custom_resolver_classes = cls.custom_resolvers.get(game_name, {})
if resolver_name not in DEFAULT_RESOLVERS and resolver_name not in custom_resolver_classes:
raise ValueError(f"Resolver '{resolver_name}' for game '{game_name}' not found")
return custom_resolver_classes.get(resolver_name) or DEFAULT_RESOLVERS[resolver_name]
@dataclasses.dataclass(frozen=True)
class FieldResolver(ABC):
@abstractmethod
def resolve(self, world: "World") -> Any: ...
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this resolver"""
fields = {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)}
return {
"resolver": self.__class__.__name__,
**fields,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Returns a new instance of this resolver from a serialized dict representation"""
assert data.get("resolver", None) == cls.__name__
return cls(**{k: v for k, v in data.items() if k != "resolver"})
@override
def __str__(self) -> str:
return self.__class__.__name__
@classmethod
def __init_subclass__(cls, /, game: str) -> None:
if game != "Archipelago":
custom_resolvers = FieldResolverRegister.custom_resolvers.setdefault(game, {})
if cls.__qualname__ in custom_resolvers:
raise TypeError(f"Resolver {cls.__qualname__} has already been registered for game {game}")
custom_resolvers[cls.__qualname__] = cls
elif cls.__module__ != "rule_builder.field_resolvers":
raise TypeError("You cannot define custom resolvers for the base Archipelago world")
@dataclasses.dataclass(frozen=True)
class FromOption(FieldResolver, game="Archipelago"):
option: type[Option[Any]]
field: str = "value"
@override
def resolve(self, world: "World") -> Any:
option_name = next(
(name for name, cls in world.options.__class__.type_hints.items() if cls is self.option),
None,
)
if option_name is None:
raise ValueError(
f"Cannot find option {self.option.__name__} in options class {world.options.__class__.__name__}"
)
opt = cast(Option[Any] | None, getattr(world.options, option_name, None))
if opt is None:
raise ValueError(f"Invalid option: {option_name}")
return getattr(opt, self.field)
@override
def to_dict(self) -> dict[str, Any]:
return {
"resolver": "FromOption",
"option": f"{self.option.__module__}.{self.option.__name__}",
"field": self.field,
}
@override
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
if "option" not in data:
raise ValueError("Missing required option")
option_path = data["option"]
try:
option_mod_name, option_cls_name = option_path.rsplit(".", 1)
option_module = importlib.import_module(option_mod_name)
option = getattr(option_module, option_cls_name, None)
except (ValueError, ImportError) as e:
raise ValueError(f"Cannot parse option '{option_path}'") from e
if option is None or not issubclass(option, Option):
raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass")
return cls(cast(type[Option[Any]], option), data.get("field", "value"))
@override
def __str__(self) -> str:
field = f".{self.field}" if self.field != "value" else ""
return f"FromOption({self.option.__name__}{field})"
@dataclasses.dataclass(frozen=True)
class FromWorldAttr(FieldResolver, game="Archipelago"):
name: str
@override
def resolve(self, world: "World") -> Any:
obj: Any = world
for field in self.name.split("."):
if obj is None:
return None
if isinstance(obj, Mapping):
obj = obj.get(field, None) # pyright: ignore[reportUnknownMemberType]
else:
obj = getattr(obj, field, None)
return obj
@override
def __str__(self) -> str:
return f"FromWorldAttr({self.name})"
T = TypeVar("T")
@overload
def resolve_field(field: Any, world: "World", expected_type: type[T]) -> T: ...
@overload
def resolve_field(field: Any, world: "World", expected_type: None = None) -> Any: ...
def resolve_field(field: Any, world: "World", expected_type: type[T] | None = None) -> T | Any:
if isinstance(field, FieldResolver):
field = field.resolve(world)
if expected_type:
assert isinstance(field, expected_type), f"Expected type {expected_type} but got {type(field)}"
return field
DEFAULT_RESOLVERS = {
resolver_name: resolver_class
for resolver_name, resolver_class in locals().items()
if isinstance(resolver_class, type)
and issubclass(resolver_class, FieldResolver)
and resolver_class is not FieldResolver
}
+239 -41
View File
@@ -7,6 +7,7 @@ from typing_extensions import TypeVar, dataclass_transform, override
from BaseClasses import CollectionState
from NetUtils import JSONMessagePart
from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field
from .options import OptionFilter
if TYPE_CHECKING:
@@ -35,7 +36,7 @@ def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., in
class CustomRuleRegister(type):
"""A metaclass to contain world custom rules and automatically convert resolved rules to frozen dataclasses"""
resolved_rules: ClassVar[dict[int, "Rule.Resolved"]] = {}
resolved_rules: ClassVar[dict["Rule.Resolved", "Rule.Resolved"]] = {}
"""A cached of resolved rules to turn each unique one into a singleton"""
custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {}
@@ -63,10 +64,9 @@ class CustomRuleRegister(type):
@override
def __call__(cls, *args: Any, **kwds: Any) -> Any:
rule = super().__call__(*args, **kwds)
rule_hash = hash(rule)
if rule_hash in cls.resolved_rules:
return cls.resolved_rules[rule_hash]
cls.resolved_rules[rule_hash] = rule
if rule in cls.resolved_rules:
return cls.resolved_rules[rule]
cls.resolved_rules[rule] = rule
return rule
@classmethod
@@ -108,11 +108,14 @@ class Rule(Generic[TWorld]):
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this rule"""
args = {
field.name: getattr(self, field.name, None)
for field in dataclasses.fields(self)
if field.name not in ("options", "filtered_resolution")
}
args = {}
for field in dataclasses.fields(self):
if field.name in ("options", "filtered_resolution"):
continue
value = getattr(self, field.name, None)
if isinstance(value, FieldResolver):
value = value.to_dict()
args[field.name] = value
return {
"rule": self.__class__.__qualname__,
"options": [o.to_dict() for o in self.options],
@@ -124,7 +127,19 @@ class Rule(Generic[TWorld]):
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
"""Returns a new instance of this rule from a serialized dict representation"""
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False))
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
return cls(**args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@classmethod
def _parse_field_resolvers(cls, data: Mapping[str, Any], game_name: str) -> dict[str, Any]:
result: dict[str, Any] = {}
for name, value in data.items():
if isinstance(value, dict) and "resolver" in value:
resolver_cls = FieldResolverRegister.get_resolver_cls(game_name, value["resolver"]) # pyright: ignore[reportUnknownArgumentType]
result[name] = resolver_cls.from_dict(value) # pyright: ignore[reportUnknownArgumentType]
else:
result[name] = value
return result
def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]":
"""Combines two rules or a rule and an option filter into an And rule"""
@@ -410,13 +425,142 @@ class NestedRule(Rule[TWorld], game="Archipelago"):
return combined_deps
class AtLeast(NestedRule[TWorld], game="Archipelago"):
"""A rule that returns true when at least N child rules evaluate as true"""
count: int | FieldResolver
def __init__(
self,
count: int | FieldResolver,
*children: Rule[TWorld],
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
super().__init__(*children, options=options, filtered_resolution=filtered_resolution)
self.count = count
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
if count == 0:
return True_().resolve(world)
children_to_process = [c.resolve(world) for c in self.children]
return AtLeast.from_resolved(count, world, children_to_process)
@classmethod
def from_resolved(cls, count: int, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
clauses: list[Rule.Resolved] = []
while children_to_process:
child = children_to_process.pop(0)
if child.always_true:
if count == 1:
return child
count -= 1
continue
if child.always_false:
# falses can be ignored
continue
clauses.append(child)
if len(clauses) < count:
return False_().resolve(world)
if count == 1:
# Switch to Or which has more optimized handling
return Or.from_resolved(world, clauses)
if count == len(clauses):
# Switch to And which has more optimized handling
return And.from_resolved(world, clauses)
return AtLeast.Resolved(
tuple(clauses),
count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
count = self.count
output["count"] = count.to_dict() if isinstance(count, FieldResolver) else count
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = cls._parse_field_resolvers(data, world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
children = [world_cls.rule_from_dict(c) for c in data.get("children", ())]
return cls(
args.pop("count"),
*children,
options=options,
filtered_resolution=data.get("filtered_resolution", False),
)
class Resolved(NestedRule.Resolved):
count: int
@override
def _evaluate(self, state: CollectionState) -> bool:
count = self.count
for rule in self.children:
if rule(state):
if count == 1:
return True
count -= 1
return False
@override
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
messages: list[JSONMessagePart] = []
if state is None:
messages = [
{"type": "text", "text": "At least "},
{"type": "color", "color": "cyan", "text": str(self.count)},
{"type": "text", "text": " of ("},
]
else:
satisfied_count = sum(1 if child(state) else 0 for child in self.children)
messages = [
{"type": "text", "text": "At least "},
{"type": "color", "color": "cyan", "text": f"{satisfied_count}/{self.count}"},
{"type": "text", "text": " of ("},
]
for i, child in enumerate(self.children):
if i > 0:
messages.append({"type": "text", "text": ", "})
messages.extend(child.explain_json(state))
messages.append({"type": "text", "text": ")"})
return messages
@override
def explain_str(self, state: CollectionState | None = None) -> str:
clauses = ", ".join([c.explain_str(state) for c in self.children])
if state is None:
return f"At least {self.count} of ({clauses})"
satisfied_count = sum(1 if child(state) else 0 for child in self.children)
return f"At least {satisfied_count}/{self.count} of ({clauses})"
@override
def __str__(self) -> str:
clauses = ", ".join([str(c) for c in self.children])
return f"At least {self.count} of ({clauses})"
@dataclasses.dataclass(init=False)
class And(NestedRule[TWorld], game="Archipelago"):
"""A rule that only returns true when all child rules evaluate as true"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
children_to_process = [c.resolve(world) for c in self.children]
return And.from_resolved(world, [c.resolve(world) for c in self.children])
@classmethod
def from_resolved(cls, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
clauses: list[Rule.Resolved] = []
items: dict[str, int] = {}
true_rule: Rule.Resolved | None = None
@@ -449,7 +593,7 @@ class And(NestedRule[TWorld], game="Archipelago"):
clauses.append(child)
if not clauses and not items:
return true_rule or False_().resolve(world)
return true_rule or True_().resolve(world)
if len(items) == 1:
item, count = next(iter(items.items()))
@@ -503,7 +647,10 @@ class Or(NestedRule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
children_to_process = [c.resolve(world) for c in self.children]
return Or.from_resolved(world, [c.resolve(world) for c in self.children])
@classmethod
def from_resolved(cls, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
clauses: list[Rule.Resolved] = []
items: dict[str, int] = {}
@@ -527,7 +674,7 @@ class Or(NestedRule[TWorld], game="Archipelago"):
items[item] = 1
elif isinstance(child, HasAnyCount.Resolved):
for item, count in child.item_counts:
if item not in items or items[item] < count:
if item not in items or count < items[item]:
items[item] = count
else:
clauses.append(child)
@@ -688,24 +835,27 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"):
class Has(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has at least `count` of a given item"""
item_name: str
item_name: str | FieldResolver
"""The item to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The count the player is required to have"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
if count <= 0:
return True_().resolve(world)
return self.Resolved(
self.item_name,
self.count,
resolve_field(self.item_name, world, str),
count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name}{count}{options})"
@@ -991,7 +1141,7 @@ class HasAny(Rule[TWorld], game="Archipelago"):
class HasAllCounts(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has all of the specified counts of the given items"""
item_counts: dict[str, int]
item_counts: Mapping[str, int | FieldResolver]
"""A mapping of item name to count to check for"""
@override
@@ -1002,12 +1152,30 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
tuple(self.item_counts.items()),
item_counts,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1096,7 +1264,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
class HasAnyCount(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has any of the specified counts of the given items"""
item_counts: dict[str, int]
item_counts: Mapping[str, int | FieldResolver]
"""A mapping of item name to count to check for"""
@override
@@ -1107,12 +1275,30 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
tuple(self.item_counts.items()),
item_counts,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1204,13 +1390,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int = 1,
count: int | FieldResolver = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1220,14 +1406,16 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
if count <= 0:
return True_().resolve(world)
if len(self.item_names) == 0:
# match state.has_from_list
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0], self.count).resolve(world)
return self.Resolved(
self.item_names,
self.count,
count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1235,7 +1423,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = {**data.get("args", {})}
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1338,13 +1526,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int = 1,
count: int | FieldResolver = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1354,14 +1542,16 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
if len(self.item_names) == 0 or len(self.item_names) < self.count:
# match state.has_from_list_unique
count = resolve_field(self.count, world, int)
if count <= 0:
return True_().resolve(world)
if len(self.item_names) < count:
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0]).resolve(world)
return self.Resolved(
self.item_names,
self.count,
count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1369,7 +1559,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = {**data.get("args", {})}
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1468,23 +1658,26 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""The name of the item group containing the items"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
if count <= 0:
return True_().resolve(world)
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
return self.Resolved(
self.item_name_group,
item_names,
self.count,
count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
@@ -1542,23 +1735,28 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""The name of the item group containing the items"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
count = resolve_field(self.count, world, int)
if count <= 0:
return True_().resolve(world)
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
if len(item_names) < count:
return False_().resolve(world)
return self.Resolved(
self.item_name_group,
item_names,
self.count,
count=count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
+9 -5
View File
@@ -98,6 +98,8 @@ class Group:
self._changed = True
attr = new
# resolve the path immediately when accessing it
if attr.exists():
attr.__class__.validate(attr.resolve())
return attr.__class__(attr.resolve())
return attr
@@ -633,10 +635,6 @@ class ServerOptions(Group):
class GeneratorOptions(Group):
"""Options for Generation"""
class EnemizerPath(LocalFilePath):
"""Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases"""
is_exe = True
class PlayerFilesPath(OptionalUserFolderPath):
"""Folder from which the player yaml files are pulled from"""
# created on demand, so marked as optional
@@ -644,6 +642,12 @@ class GeneratorOptions(Group):
class Players(int):
"""amount of players, 0 to infer from player files"""
class AllowQuantity(Bool):
"""
allow players to set an individual quantity for their yaml settings
with 'false' any amounts from the players will be ignored and set to 1
"""
class WeightsFilePath(str):
"""
general weights file, within the stated player_files_path location
@@ -687,9 +691,9 @@ class GeneratorOptions(Group):
start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
"""
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
players: Players = Players(0)
allow_quantity: AllowQuantity | bool = False
weights_file_path: WeightsFilePath = WeightsFilePath("weights.yaml")
meta_file_path: MetaFilePath = MetaFilePath("meta.yaml")
spoiler: Spoiler = Spoiler(3)
+3 -5
View File
@@ -71,7 +71,6 @@ non_apworlds: set[str] = {
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Sudoku",
"Super Mario 64",
"VVVVVV",
"Wargroove",
@@ -202,7 +201,7 @@ if is_windows:
icon=resolve_icon(c.icon),
))
extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"]
extra_data = ["LICENSE", "data", "SNI"]
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
@@ -457,9 +456,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
for world_directory in folders_to_remove)
else:
# make sure extra programs are executable
enemizer_exe = self.buildfolder / 'EnemizerCLI/EnemizerCLI.Core'
sni_exe = self.buildfolder / 'SNI/sni'
extra_exes = (enemizer_exe, sni_exe)
extra_exes = (sni_exe,)
for extra_exe in extra_exes:
if extra_exe.is_file():
extra_exe.chmod(0o755)
@@ -658,7 +656,7 @@ cx_Freeze.setup(
options={
"build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": [],
"includes": ["rule_builder.cached_world"],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_includes": [],
+1 -1
View File
@@ -202,7 +202,7 @@ class WorldTestBase(unittest.TestCase):
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
state = self.multiworld.get_all_state(False)
state = self.multiworld.get_all_state()
for location in self.multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
reachable = location.can_reach(state)
+1 -1
View File
@@ -88,7 +88,7 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
if not locations:
continue
all_state = multiworld.get_all_state(False)
all_state = multiworld.get_all_state()
for location in locations:
time_taken = self.location_test(location, multiworld.state, "empty_state")
summary_data["empty_state"][location.name] = time_taken
+2
View File
@@ -70,10 +70,12 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
class TestWebWorld(WebWorld):
__test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
tutorials = []
class TestWorld(World):
__test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
game = f"Test Game"
item_name_to_id = {}
location_name_to_id = {}
+4 -2
View File
@@ -4,6 +4,8 @@ from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
world_relevant = True
def test_entrance_connection_steps(self):
"""Tests that Entrances are connected and not changed after connect_entrances."""
def get_entrance_name_to_source_and_target_dict(world: World):
@@ -15,7 +17,7 @@ class TestBase(unittest.TestCase):
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
additional_steps = ("generate_basic", "pre_fill")
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -42,7 +44,7 @@ class TestBase(unittest.TestCase):
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, ())
+4 -2
View File
@@ -4,11 +4,13 @@ from worlds.AutoWorld import AutoWorldRegister
class TestNameGroups(TestCase):
world_relevant = True
def test_item_name_groups_not_empty(self) -> None:
"""
Test that there are no empty item name groups, which is likely a bug.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.item_id_to_name:
continue # ignore worlds without items
with self.subTest(game=game_name):
@@ -19,7 +21,7 @@ class TestNameGroups(TestCase):
"""
Test that there are no empty location name groups, which is likely a bug.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.location_id_to_name:
continue # ignore worlds without locations
with self.subTest(game=game_name):
+9 -7
View File
@@ -7,23 +7,25 @@ from . import setup_solo_multiworld
class TestIDs(unittest.TestCase):
world_relevant = True
def test_range_items(self):
"""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.testable_worlds.items():
with self.subTest(game=gamename):
for item_id in world_type.item_id_to_name:
self.assertLess(item_id, 2**53)
def test_range_locations(self):
"""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.testable_worlds.items():
with self.subTest(game=gamename):
for location_id in world_type.location_id_to_name:
self.assertLess(location_id, 2**53)
def test_reserved_items(self):
"""negative item IDs are reserved to the special "Archipelago" world."""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
if gamename == "Archipelago":
for item_id in world_type.item_id_to_name:
@@ -34,7 +36,7 @@ class TestIDs(unittest.TestCase):
def test_reserved_locations(self):
"""negative location IDs are reserved to the special "Archipelago" world."""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
if gamename == "Archipelago":
for location_id in world_type.location_id_to_name:
@@ -45,7 +47,7 @@ class TestIDs(unittest.TestCase):
def test_duplicate_item_ids(self):
"""Test that a game doesn't have item id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
len_item_id_to_name = len(world_type.item_id_to_name)
len_item_name_to_id = len(world_type.item_name_to_id)
@@ -64,7 +66,7 @@ class TestIDs(unittest.TestCase):
def test_duplicate_location_ids(self):
"""Test that a game doesn't have location id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
len_location_id_to_name = len(world_type.location_id_to_name)
len_location_name_to_id = len(world_type.location_name_to_id)
@@ -83,7 +85,7 @@ class TestIDs(unittest.TestCase):
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld)
+13 -11
View File
@@ -8,17 +8,19 @@ from . import setup_solo_multiworld
class TestImplemented(unittest.TestCase):
world_relevant = True
def test_completion_condition(self):
"""Ensure a completion condition is set that has requirements."""
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden and game_name not in {"Sudoku"}:
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
self.assertFalse(multiworld.completion_condition[1](multiworld.state))
def test_entrance_parents(self):
"""Tests that the parents of created Entrances match the exiting Region."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
@@ -28,7 +30,7 @@ class TestImplemented(unittest.TestCase):
def test_stage_methods(self):
"""Tests that worlds don't try to implement certain steps that are only ever called as stage."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
for method in ("assert_generate",):
@@ -40,7 +42,7 @@ class TestImplemented(unittest.TestCase):
# has an await for generate_output which isn't being called
excluded_games = ("Ocarina of Time",)
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
@@ -54,16 +56,16 @@ class TestImplemented(unittest.TestCase):
def test_no_failed_world_loads(self):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")
self.fail(f"The following worlds failed to load: {failed_world_loads.keys()}")
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
allstate = multiworld.get_all_state(False)
allstate = multiworld.get_all_state()
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
@@ -78,7 +80,7 @@ class TestImplemented(unittest.TestCase):
# Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is
# nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect
# conditions.
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type)
world = multiworld.get_game_worlds(game_name)[0]
if not world.explicit_indirect_conditions:
@@ -140,7 +142,7 @@ class TestImplemented(unittest.TestCase):
def test_no_items_or_locations_or_regions_submitted_in_init(self):
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, ())
self.assertEqual(len(multiworld.itempool), 0)
+10 -8
View File
@@ -11,9 +11,11 @@ from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
world_relevant = True
def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
proxy_world = multiworld.worlds[1]
for item_name in world_type.item_name_to_id:
@@ -53,7 +55,7 @@ class TestBase(unittest.TestCase):
"Yu-Gi-Oh! 2006":
{"Campaign Boss Beaten"}
}
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
exclusions = exclusion_dict.get(game_name, frozenset())
for group_name, items in world_type.item_name_groups.items():
@@ -64,7 +66,7 @@ class TestBase(unittest.TestCase):
def test_item_name_group_conflict(self):
"""Test that all item name groups aren't also item names."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
for group_name in world_type.item_name_groups:
with self.subTest(group_name, group_name=group_name):
@@ -72,7 +74,7 @@ class TestBase(unittest.TestCase):
def test_item_count_equal_locations(self):
"""Test that by the pre_fill step under default settings, each game submits items == locations"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
self.assertEqual(
@@ -84,7 +86,7 @@ class TestBase(unittest.TestCase):
def test_items_in_datapackage(self):
"""Test that any created items in the itempool are in the datapackage"""
archipelago = AutoWorldRegister.world_types["Archipelago"]
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
@@ -126,7 +128,7 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "finalize_multiworld")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Can generate with link replacement", game=game_name):
setup_link_multiworld(world_type, True)
with self.subTest("Can generate without link replacement", game=game_name):
@@ -138,7 +140,7 @@ class TestBase(unittest.TestCase):
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -153,7 +155,7 @@ class TestBase(unittest.TestCase):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy()
+6 -4
View File
@@ -5,9 +5,11 @@ from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
world_relevant = True
def test_create_duplicate_locations(self):
"""Tests that no two Locations share a name or ID."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
multiworld = setup_solo_multiworld(world_type)
locations = Counter(location.name for location in multiworld.get_locations())
if locations:
@@ -22,7 +24,7 @@ class TestBase(unittest.TestCase):
def test_locations_in_datapackage(self):
"""Tests that created locations not filled before fill starts exist in the datapackage."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type)
locations = multiworld.get_unfilled_locations() # do unfilled locations to avoid Events
@@ -35,7 +37,7 @@ class TestBase(unittest.TestCase):
gen_steps = ("generate_early", "create_regions", "create_items")
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
@@ -68,7 +70,7 @@ class TestBase(unittest.TestCase):
def test_location_group(self):
"""Test that all location name groups contain valid locations and don't share names."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game_name, game_name=game_name):
for group_name, locations in world_type.location_name_groups.items():
with self.subTest(group_name, group_name=group_name):
+3 -1
View File
@@ -6,12 +6,14 @@ from . import setup_solo_multiworld
class TestWorldMemory(unittest.TestCase):
world_relevant = True
def test_leak(self) -> None:
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc
import weakref
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game creation", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type))
refs[game_name] = weak
+4 -2
View File
@@ -3,9 +3,11 @@ from worlds.AutoWorld import AutoWorldRegister
class TestNames(unittest.TestCase):
world_relevant = True
def test_item_names_format(self) -> None:
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for item_name in world_type.item_name_to_id:
self.assertFalse(item_name.isnumeric(),
@@ -13,7 +15,7 @@ class TestNames(unittest.TestCase):
def test_location_name_format(self) -> None:
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
for location_name in world_type.location_name_to_id:
self.assertFalse(location_name.isnumeric(),
+32 -7
View File
@@ -1,31 +1,56 @@
import unittest
from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Options import Choice, TextChoice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
class TestOptions(unittest.TestCase):
world_relevant = True
def test_options_have_doc_string(self):
"""Test that submitted options have their own specified docstring"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
self.assertTrue(option.__doc__)
def test_option_defaults(self):
"""Test that defaults for submitted options are valid."""
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
if issubclass(option, TextChoice):
self.assertTrue(option.default in option.name_lookup,
f"Default value {option.default} for TextChoice option {option.__name__} in"
f" {gamename} does not resolve to a listed value!"
)
# Standard "can default generate" test
err_raised = None
try:
option.from_any(option.default)
except Exception as ex:
err_raised = ex
self.assertIsNone(err_raised,
f"Default value {option.default} for option {option.__name__} in {gamename}"
f" is not valid! Exception: {err_raised}"
)
def test_options_are_not_set_by_world(self):
"""Test that options attribute is not already set"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=gamename):
self.assertFalse(hasattr(world_type, "options"),
f"Unexpected assignment to {world_type.__name__}.options!")
def test_duplicate_options(self) -> None:
"""Tests that a world doesn't reuse the same option class."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=game_name):
seen_options = set()
for option in world_type.options_dataclass.type_hints.values():
@@ -75,7 +100,7 @@ class TestOptions(unittest.TestCase):
def test_pickle_dumps_default(self):
"""Test that default option values can be pickled into database for WebHost generation"""
for gamename, world_type in AutoWorldRegister.world_types.items():
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
@@ -85,8 +110,8 @@ class TestOptions(unittest.TestCase):
def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if game_name not in ("Archipelago", "Super Metroid"):
for option_key, option in world_type.options_dataclass.type_hints.items():
if issubclass(option, OptionSet):
with self.subTest(game=game_name, option=option_key):
+2
View File
@@ -3,6 +3,8 @@ import os
class TestPackages(unittest.TestCase):
world_relevant = True
def test_packages_have_init(self):
"""Test that all world folders containing .py files also have a __init__.py file,
to indicate full package rather than namespace package."""
+2
View File
@@ -4,6 +4,8 @@ from worlds.Files import AutoPatchRegister
class TestPatches(unittest.TestCase):
world_relevant = True
def test_patch_name_matches_game(self) -> None:
for game_name in AutoPatchRegister.patch_types:
with self.subTest(game=game_name):
+4 -3
View File
@@ -6,6 +6,7 @@ from . import setup_solo_multiworld, gen_steps
class TestBase(unittest.TestCase):
world_relevant = True
gen_steps = gen_steps
default_settings_unreachable_regions = {
@@ -45,11 +46,11 @@ class TestBase(unittest.TestCase):
def test_default_all_state_can_reach_everything(self):
"""Ensure all state can reach everything and complete the game with the defined options"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
state = multiworld.get_all_state(False)
state = multiworld.get_all_state()
for location in multiworld.get_locations():
with self.subTest("Location should be reached", location=location.name):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
@@ -67,7 +68,7 @@ class TestBase(unittest.TestCase):
def test_default_empty_state_can_reach_something(self):
"""Ensure empty state can reach at least one location with the defined options"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
state = CollectionState(multiworld)
+2
View File
@@ -3,6 +3,8 @@ import os
class TestBase(unittest.TestCase):
world_relevant = True
def test_requirements_file_ends_on_newline(self):
"""Test that all requirements files end on a newline"""
import Utils
+216 -37
View File
@@ -6,11 +6,13 @@ from typing_extensions import override
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from NetUtils import JSONMessagePart
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Range, Toggle
from rule_builder.cached_world import CachedRuleBuilderWorld
from rule_builder.field_resolvers import FieldResolver, FromOption, FromWorldAttr, resolve_field
from rule_builder.options import Operator, OptionFilter
from rule_builder.rules import (
And,
AtLeast,
CanReachEntrance,
CanReachLocation,
CanReachRegion,
@@ -59,12 +61,20 @@ class SetOption(OptionSet):
valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride]
class RangeOption(Range):
auto_display_name = True
range_start = 1
range_end = 10
default = 5
@dataclass
class RuleBuilderOptions(PerGameCommonOptions):
toggle_option: ToggleOption
choice_option: ChoiceOption
text_option: FreeTextOption
set_option: SetOption
range_option: RangeOption
GAME_NAME = "Rule Builder Test Game"
@@ -149,6 +159,18 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
@classvar_matrix(
rules=(
(
And(),
True_.Resolved(player=1)
),
(
Or(),
False_.Resolved(player=1)
),
(
Has("A", 0),
True_.Resolved(player=1)
),
(
And(Has("A", 1), Has("A", 2)),
Has.Resolved("A", 2, player=1),
@@ -233,6 +255,48 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})),
HasAny.Resolved(("A", "B", "C", "D", "E"), player=1),
),
(
And(HasAllCounts({"A": 1, "B": 2}), HasAllCounts({"A": 2, "B": 2})),
HasAllCounts.Resolved((("A", 2), ("B", 2)), player=1),
),
(
Or(HasAnyCount({"A": 1, "B": 2}), HasAnyCount({"A": 2, "B": 2})),
HasAnyCount.Resolved((("A", 1), ("B", 2)), player=1),
),
(
AtLeast(0, Has("A")),
True_.Resolved(player=1),
),
(
AtLeast(3, True_(), Has("A"), Has("B"), Has("C")),
AtLeast.Resolved(
(Has.Resolved("A", player=1), Has.Resolved("B", player=1), Has.Resolved("C", player=1)), 2, player=1
),
),
(
AtLeast(2, False_(), Has("A"), Has("B"), Has("C")),
AtLeast.Resolved(
(Has.Resolved("A", player=1), Has.Resolved("B", player=1), Has.Resolved("C", player=1)), 2, player=1
),
),
(
AtLeast(2, True_(), True_(), Has("A")),
True_.Resolved(player=1),
),
(
AtLeast(3, Has("A"), Has("B")),
False_.Resolved(player=1),
),
(
# This test will fail when Or(Rule, Rule) will be optimized to Rule
AtLeast(1, Rule(), Rule()),
Or.Resolved((Rule.Resolved(player=1), Rule.Resolved(player=1)), player=1),
),
(
# This test will fail when And(Rule, Rule) will be optimized to Rule
AtLeast(2, Rule(), Rule()),
And.Resolved((Rule.Resolved(player=1), Rule.Resolved(player=1)), player=1),
),
)
)
class TestSimplify(RuleBuilderTestCase):
@@ -399,6 +463,15 @@ class TestHashes(RuleBuilderTestCase):
rule2 = HasAll("2", "2", "2", "1")
self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world)))
def test_hash_collision(self) -> None:
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
world = multiworld.worlds[1]
rule1 = Has("A", count=1).resolve(world)
rule2 = Has("A", count=1 << 61).resolve(world)
self.assertEqual(hash(rule1), hash(rule2))
self.assertNotEqual(rule1, rule2)
self.assertNotEqual(id(rule1), id(rule2))
class TestCaching(CachedRuleBuilderTestCase):
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
@@ -605,6 +678,24 @@ class TestRules(RuleBuilderTestCase):
self.state.remove(item)
self.assertFalse(resolved_rule(self.state))
def test_at_least(self) -> None:
# Has has to be relied on as True_ and False_ would be optimized out
rule = AtLeast(2, Has("Item 1"), Has("Item 1"), Has("Item 2"), Has("Item 3"))
resolved_rule = rule.resolve(self.world)
self.world.register_rule_dependencies(resolved_rule)
item1 = self.world.create_item("Item 1")
item2 = self.world.create_item("Item 2")
item3 = self.world.create_item("Item 3")
self.assertFalse(resolved_rule(self.state))
self.state.collect(item1)
self.assertTrue(resolved_rule(self.state))
self.state.collect(item2)
self.assertTrue(resolved_rule(self.state))
self.state.remove(item1)
self.assertFalse(resolved_rule(self.state))
self.state.collect(item3)
self.assertTrue(resolved_rule(self.state))
def test_has_all(self) -> None:
rule = HasAll("Item 1", "Item 2")
resolved_rule = rule.resolve(self.world)
@@ -651,14 +742,15 @@ class TestRules(RuleBuilderTestCase):
self.assertFalse(resolved_rule(self.state))
def test_has_any_count(self) -> None:
item_counts = {"Item 1": 1, "Item 2": 2}
item_counts: dict[str, int | FieldResolver] = {"Item 1": 1, "Item 2": 2}
rule = HasAnyCount(item_counts)
resolved_rule = rule.resolve(self.world)
self.world.register_rule_dependencies(resolved_rule)
for item_name, count in item_counts.items():
item = self.world.create_item(item_name)
for _ in range(count):
num_items = resolve_field(count, self.world, int)
for _ in range(num_items):
self.assertFalse(resolved_rule(self.state))
self.state.collect(item)
self.assertTrue(resolved_rule(self.state))
@@ -755,7 +847,7 @@ class TestSerialization(RuleBuilderTestCase):
rule: ClassVar[Rule[Any]] = And(
Or(
Has("i1", count=4),
Has("i1", count=FromOption(RangeOption)),
HasFromList("i2", "i3", "i4", count=2),
HasAnyCount({"i5": 2, "i6": 3}),
options=[OptionFilter(ToggleOption, 0)],
@@ -763,7 +855,7 @@ class TestSerialization(RuleBuilderTestCase):
Or(
HasAll("i7", "i8"),
HasAllCounts(
{"i9": 1, "i10": 5},
{"i9": 1, "i10": FromWorldAttr("instance_data.i10_count")},
options=[OptionFilter(ToggleOption, 1, operator="ne")],
filtered_resolution=True,
),
@@ -779,8 +871,13 @@ class TestSerialization(RuleBuilderTestCase):
OptionFilter(ChoiceOption, ChoiceOption.option_second, "ge"),
],
),
AtLeast(
FromWorldAttr("instance_data.at_least_requirement"),
Has("i15", count=2),
HasGroup("g2", count=3),
),
CanReachEntrance("e1"),
HasGroupUnique("g2", count=5),
HasGroupUnique("g3", count=5),
)
rule_dict: ClassVar[dict[str, Any]] = {
@@ -803,7 +900,14 @@ class TestSerialization(RuleBuilderTestCase):
"rule": "Has",
"options": [],
"filtered_resolution": False,
"args": {"item_name": "i1", "count": 4},
"args": {
"item_name": "i1",
"count": {
"resolver": "FromOption",
"option": "test.general.test_rule_builder.RangeOption",
"field": "value",
},
},
},
{
"rule": "HasFromList",
@@ -840,7 +944,12 @@ class TestSerialization(RuleBuilderTestCase):
},
],
"filtered_resolution": True,
"args": {"item_counts": {"i9": 1, "i10": 5}},
"args": {
"item_counts": {
"i9": 1,
"i10": {"resolver": "FromWorldAttr", "name": "instance_data.i10_count"},
}
},
},
{
"rule": "CanReachRegion",
@@ -892,6 +1001,29 @@ class TestSerialization(RuleBuilderTestCase):
},
],
},
{
"rule": "AtLeast",
"options": [],
"filtered_resolution": False,
"count": {"resolver": "FromWorldAttr", "name": "instance_data.at_least_requirement"},
"children": [
{
"rule": "Has",
"options": [],
"filtered_resolution": False,
"args": {
"item_name": "i15",
"count": 2,
},
},
{
"rule": "HasGroup",
"options": [],
"filtered_resolution": False,
"args": {"item_name_group": "g2", "count": 3},
},
],
},
{
"rule": "CanReachEntrance",
"options": [],
@@ -902,7 +1034,7 @@ class TestSerialization(RuleBuilderTestCase):
"rule": "HasGroupUnique",
"options": [],
"filtered_resolution": False,
"args": {"item_name_group": "g2", "count": 5},
"args": {"item_name_group": "g3", "count": 5},
},
],
}
@@ -915,7 +1047,7 @@ class TestSerialization(RuleBuilderTestCase):
multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0)
world = multiworld.worlds[1]
deserialized_rule = world.rule_from_dict(self.rule_dict)
self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule))
self.assertEqual(deserialized_rule, self.rule, f"\n{deserialized_rule}\n{self.rule}")
class TestExplain(RuleBuilderTestCase):
@@ -934,9 +1066,15 @@ class TestExplain(RuleBuilderTestCase):
),
player=1,
),
HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1),
HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1),
HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1),
AtLeast.Resolved(
children=(
HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1),
HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1),
HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1),
),
count=2,
player=1,
),
HasFromListUnique.Resolved(("Item 13", "Item 14"), player=1),
HasGroup.Resolved("Group 1", ("Item 15", "Item 16", "Item 17"), player=1),
HasGroupUnique.Resolved("Group 2", ("Item 18", "Item 19"), count=2, player=1),
@@ -1001,6 +1139,9 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "At least "},
{"type": "color", "color": "cyan", "text": "0/2"},
{"type": "text", "text": " of ("},
{"type": "text", "text": "Missing "},
{"type": "color", "color": "cyan", "text": "some"},
{"type": "text", "text": " of ("},
@@ -1011,7 +1152,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "salmon", "text": "Item 7"},
{"type": "text", "text": " x5"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Missing "},
{"type": "color", "color": "cyan", "text": "all"},
{"type": "text", "text": " of ("},
@@ -1022,7 +1163,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "salmon", "text": "Item 9"},
{"type": "text", "text": " x3"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "salmon", "text": "0/2"},
{"type": "text", "text": " items from ("},
@@ -1033,6 +1174,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ", "},
{"type": "color", "color": "salmon", "text": "Item 12"},
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "salmon", "text": "0/1"},
@@ -1099,6 +1241,9 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "At least "},
{"type": "color", "color": "cyan", "text": "3/2"},
{"type": "text", "text": " of ("},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "all"},
{"type": "text", "text": " of ("},
@@ -1109,7 +1254,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "green", "text": "Item 7"},
{"type": "text", "text": " x5"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "some"},
{"type": "text", "text": " of ("},
@@ -1120,7 +1265,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "green", "text": "Item 9"},
{"type": "text", "text": " x3"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "green", "text": "30/2"},
{"type": "text", "text": " items from ("},
@@ -1131,6 +1276,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ", "},
{"type": "color", "color": "green", "text": "Item 12"},
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "green", "text": "2/1"},
@@ -1165,7 +1311,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "salmon", "text": "False"},
{"type": "text", "text": ")"},
]
assert self.resolved_rule.explain_json(self.state) == expected
self.assertEqual(self.resolved_rule.explain_json(self.state), expected)
def test_explain_json_without_state(self) -> None:
expected: list[JSONMessagePart] = [
@@ -1193,6 +1339,9 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "At least "},
{"type": "color", "color": "cyan", "text": "2"},
{"type": "text", "text": " of ("},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "all"},
{"type": "text", "text": " of ("},
@@ -1202,7 +1351,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "item_name", "flags": 1, "text": "Item 7", "player": 1},
{"type": "text", "text": " x5"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "any"},
{"type": "text", "text": " of ("},
@@ -1212,7 +1361,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "item_name", "flags": 1, "text": "Item 9", "player": 1},
{"type": "text", "text": " x3"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": ", "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "2"},
{"type": "text", "text": "x items from ("},
@@ -1222,6 +1371,7 @@ class TestExplain(RuleBuilderTestCase):
{"type": "text", "text": ", "},
{"type": "item_name", "flags": 1, "text": "Item 12", "player": 1},
{"type": "text", "text": ")"},
{"type": "text", "text": ")"},
{"type": "text", "text": " & "},
{"type": "text", "text": "Has "},
{"type": "color", "color": "cyan", "text": "1"},
@@ -1255,16 +1405,16 @@ class TestExplain(RuleBuilderTestCase):
{"type": "color", "color": "salmon", "text": "False"},
{"type": "text", "text": ")"},
]
assert self.resolved_rule.explain_json() == expected
self.assertEqual(self.resolved_rule.explain_json(), expected)
def test_explain_str_with_state_no_items(self) -> None:
expected = (
"((Missing 4x Item 1",
"| Missing some of (Missing: Item 2, Item 3)",
"| Missing all of (Missing: Item 4, Item 5))",
"& Missing some of (Missing: Item 6 x1, Item 7 x5)",
"& Missing all of (Missing: Item 8 x2, Item 9 x3)",
"& Has 0/2 items from (Missing: Item 10, Item 11, Item 12)",
"& At least 0/2 of (Missing some of (Missing: Item 6 x1, Item 7 x5),",
"Missing all of (Missing: Item 8 x2, Item 9 x3),",
"Has 0/2 items from (Missing: Item 10, Item 11, Item 12))",
"& Has 0/1 unique items from (Missing: Item 13, Item 14)",
"& Has 0/1 items from Group 1",
"& Has 0/2 unique items from Group 2",
@@ -1274,7 +1424,7 @@ class TestExplain(RuleBuilderTestCase):
"& True",
"& False)",
)
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
self.assertEqual(self.resolved_rule.explain_str(self.state), " ".join(expected))
def test_explain_str_with_state_all_items(self) -> None:
self._collect_all()
@@ -1283,9 +1433,9 @@ class TestExplain(RuleBuilderTestCase):
"((Has 4x Item 1",
"| Has all of (Found: Item 2, Item 3)",
"| Has some of (Found: Item 4, Item 5))",
"& Has all of (Found: Item 6 x1, Item 7 x5)",
"& Has some of (Found: Item 8 x2, Item 9 x3)",
"& Has 30/2 items from (Found: Item 10, Item 11, Item 12)",
"& At least 3/2 of (Has all of (Found: Item 6 x1, Item 7 x5),",
"Has some of (Found: Item 8 x2, Item 9 x3),",
"Has 30/2 items from (Found: Item 10, Item 11, Item 12))",
"& Has 2/1 unique items from (Found: Item 13, Item 14)",
"& Has 30/1 items from Group 1",
"& Has 2/2 unique items from Group 2",
@@ -1295,16 +1445,16 @@ class TestExplain(RuleBuilderTestCase):
"& True",
"& False)",
)
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
self.assertEqual(self.resolved_rule.explain_str(self.state), " ".join(expected))
def test_explain_str_without_state(self) -> None:
expected = (
"((Has 4x Item 1",
"| Has all of (Item 2, Item 3)",
"| Has any of (Item 4, Item 5))",
"& Has all of (Item 6 x1, Item 7 x5)",
"& Has any of (Item 8 x2, Item 9 x3)",
"& Has 2x items from (Item 10, Item 11, Item 12)",
"& At least 2 of (Has all of (Item 6 x1, Item 7 x5),",
"Has any of (Item 8 x2, Item 9 x3),",
"Has 2x items from (Item 10, Item 11, Item 12))",
"& Has a unique item from (Item 13, Item 14)",
"& Has an item from Group 1",
"& Has 2x unique items from Group 2",
@@ -1314,16 +1464,16 @@ class TestExplain(RuleBuilderTestCase):
"& True",
"& False)",
)
assert self.resolved_rule.explain_str() == " ".join(expected)
self.assertEqual(self.resolved_rule.explain_str(), " ".join(expected))
def test_str(self) -> None:
expected = (
"((Has 4x Item 1",
"| Has all of (Item 2, Item 3)",
"| Has any of (Item 4, Item 5))",
"& Has all of (Item 6 x1, Item 7 x5)",
"& Has any of (Item 8 x2, Item 9 x3)",
"& Has 2x items from (Item 10, Item 11, Item 12)",
"& At least 2 of (Has all of (Item 6 x1, Item 7 x5),",
"Has any of (Item 8 x2, Item 9 x3),",
"Has 2x items from (Item 10, Item 11, Item 12))",
"& Has a unique item from (Item 13, Item 14)",
"& Has an item from Group 1",
"& Has 2x unique items from Group 2",
@@ -1333,4 +1483,33 @@ class TestExplain(RuleBuilderTestCase):
"& True",
"& False)",
)
assert str(self.resolved_rule) == " ".join(expected)
self.assertEqual(str(self.resolved_rule), " ".join(expected))
@classvar_matrix(
rules=(
(
Has("A", FromOption(RangeOption)),
Has.Resolved("A", count=5, player=1),
),
(
Has("B", FromWorldAttr("pre_calculated")),
Has.Resolved("B", count=3, player=1),
),
(
Has("C", FromWorldAttr("instance_data.key")),
Has.Resolved("C", count=7, player=1),
),
)
)
class TestFieldResolvers(RuleBuilderTestCase):
rules: ClassVar[tuple[Rule[Any], Rule.Resolved]]
def test_simplify(self) -> None:
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
world = multiworld.worlds[1]
world.pre_calculated = 3 # pyright: ignore[reportAttributeAccessIssue]
world.instance_data = {"key": 7} # pyright: ignore[reportAttributeAccessIssue]
rule, expected = self.rules
resolved_rule = rule.resolve(world)
self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}")
+3 -1
View File
@@ -5,11 +5,13 @@ from worlds.AutoWorld import AutoWorldRegister
class TestSettings(TestCase):
world_relevant = True
def test_settings_can_update(self) -> None:
"""
Test that world settings can update.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest(game=game_name):
if world_type.settings is not None:
assert isinstance(world_type.settings, Group)
+3 -2
View File
@@ -5,6 +5,7 @@ from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
world_relevant = True
gen_steps = (
"generate_early",
"create_regions",
@@ -20,10 +21,10 @@ class TestBase(unittest.TestCase):
def test_all_state_is_available(self):
"""Ensure all_state can be created at certain steps."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
self.assertTrue(multiworld.get_all_state(allow_partial_entrances=True))
+2 -1
View File
@@ -22,7 +22,7 @@ worlds_paths = [
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
source_world_names = [
k
for k, v in AutoWorldRegister.world_types.items()
for k, v in AutoWorldRegister.testable_worlds.items()
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
]
@@ -43,6 +43,7 @@ def get_source_world_manifest_path(game: str) -> Path | None:
# TODO: remove the filter once manifests are mandatory.
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
class TestWorldManifest(unittest.TestCase):
world_relevant = True
game: ClassVar[str]
manifest: ClassVar[dict[str, Any]]
+6 -3
View File
@@ -6,6 +6,7 @@ import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Optional, cast
from Utils import utcnow
from WebHostLib import to_python
if TYPE_CHECKING:
@@ -133,7 +134,7 @@ def stop_room(app_client: "FlaskClient",
room_id: str,
timeout: Optional[float] = None,
simulate_idle: bool = True) -> None:
from datetime import datetime, timedelta
from datetime import timedelta
from time import sleep
from pony.orm import db_session
@@ -151,10 +152,11 @@ def stop_room(app_client: "FlaskClient",
with db_session:
room: Room = Room.get(id=room_uuid)
now = utcnow()
if simulate_idle:
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
new_last_activity = now - timedelta(seconds=room.timeout + 5)
else:
new_last_activity = datetime.utcnow() - timedelta(days=3)
new_last_activity = now - timedelta(days=3)
room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address:
@@ -188,6 +190,7 @@ def stop_room(app_client: "FlaskClient",
if address:
room.timeout = original_timeout
room.last_activity = new_last_activity
room.commands.clear() # make sure there is no leftover /exit
print("timeout restored")
+3 -2
View File
@@ -65,13 +65,14 @@ class TestAllGamesMultiworld(MultiworldTestBase):
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
@classvar_matrix(game=AutoWorldRegister.testable_worlds.keys())
class TestTwoPlayerMulti(MultiworldTestBase):
world_relevant = True
game: ClassVar[str]
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
world_type = AutoWorldRegister.world_types[self.game]
world_type = AutoWorldRegister.testable_worlds[self.game]
self.multiworld = setup_multiworld([world_type, world_type], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
+37
View File
@@ -0,0 +1,37 @@
"""Verify that NetUtils' enums work correctly with all supported Python versions."""
import pickle
import unittest
from enum import Enum
from typing import Type
from NetUtils import ClientStatus, HintStatus, SlotType
from Utils import restricted_loads
class Base:
class DataEnumTest(unittest.TestCase):
type: Type[Enum]
value: Enum
def test_unpickle(self) -> None:
"""Tests that enums used in multidata or multisave can be pickled and unpickled."""
pickled = pickle.dumps(self.value)
unpickled = restricted_loads(pickled)
self.assertEqual(unpickled, self.value)
self.assertIsInstance(unpickled, self.type)
class HintStatusTest(Base.DataEnumTest):
type = HintStatus
value = HintStatus.HINT_AVOID
class ClientStatusTest(Base.DataEnumTest):
type = ClientStatus
value = ClientStatus.CLIENT_GOAL
class SlotTypeTest(Base.DataEnumTest):
type = SlotType
value = SlotType.player
+97 -1
View File
@@ -1,6 +1,8 @@
import unittest
from Options import Choice, DefaultOnToggle, Toggle
from collections import Counter
from Options import Choice, DefaultOnToggle, Toggle, OptionDict, OptionError, OptionSet, OptionList, OptionCounter
class TestNumericOptions(unittest.TestCase):
@@ -74,3 +76,97 @@ class TestNumericOptions(unittest.TestCase):
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)
class TestContainerOptions(unittest.TestCase):
def test_option_dict(self):
class TestOptionDict(OptionDict):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_dict = {"D": "Foo"}
test_option_dict = TestOptionDict(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_dict.verify_keys)
init_dict = {"A": "foo", "B": "bar"}
test_option_dict = TestOptionDict(init_dict)
self.assertEqual(test_option_dict, init_dict) # Implicit value comparison
self.assertEqual(test_option_dict["A"], "foo")
self.assertIn("B", test_option_dict)
self.assertNotIn("C", test_option_dict)
self.assertRaises(KeyError, lambda: test_option_dict["C"])
def test_option_set(self):
class TestOptionSet(OptionSet):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_set = {"D"}
test_option_set = TestOptionSet(unknown_key_init_set)
self.assertRaises(OptionError, test_option_set.verify_keys)
init_set = {"A", "B"}
test_option_set = TestOptionSet(init_set)
self.assertEqual(test_option_set, init_set) # Implicit value comparison
self.assertIn("B", test_option_set)
self.assertNotIn("C", test_option_set)
def test_option_list(self):
class TestOptionList(OptionList):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_list = ["D"]
test_option_list = TestOptionList(unknown_key_init_list)
self.assertRaises(OptionError, test_option_list.verify_keys)
init_list = ["A", "B"]
test_option_list = TestOptionList(init_list)
self.assertEqual(test_option_list, init_list)
self.assertIn("B", test_option_list)
self.assertNotIn("C", test_option_list)
def test_option_counter(self):
class TestOptionCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
max = 10
min = 0
unknown_key_init_dict = {"D": 5}
test_option_counter = TestOptionCounter(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_keys)
wrong_value_type_init_dict = {"A": "B"}
self.assertRaises(TypeError, TestOptionCounter, wrong_value_type_init_dict)
violates_max_init_dict = {"A": 5, "B": 11}
test_option_counter = TestOptionCounter(violates_max_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
violates_min_init_dict = {"A": -1, "B": 5}
test_option_counter = TestOptionCounter(violates_min_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
init_dict = {"A": 0, "B": 10}
test_option_counter = TestOptionCounter(init_dict)
self.assertEqual(test_option_counter, Counter(init_dict))
self.assertIn("A", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0)
self.assertEqual(test_option_counter["B"], 10)
self.assertEqual(test_option_counter["C"], 0)
def test_culling_option_counter(self):
class TestCullingCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
cull_zeroes = True
init_dict = {"A": 0, "B": 10}
test_option_counter = TestCullingCounter(init_dict)
self.assertNotIn("A", test_option_counter)
self.assertIn("B", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0) # It's still a Counter! cull_zeroes is about "in" checks.
self.assertEqual(test_option_counter, Counter({"B": 10}))
+5
View File
@@ -33,4 +33,9 @@ class TestBase(unittest.TestCase):
cls.app = raw_app
def setUp(self) -> None:
from WebHostLib.models import db
from pony.orm import db_session
with db_session:
for entity in db.entities.values():
entity.select().delete(bulk=True)
self.client = self.app.test_client()
+107
View File
@@ -0,0 +1,107 @@
from datetime import timedelta
from uuid import UUID, uuid4
from pony.orm import db_session, commit
from Utils import utcnow
from WebHostLib.autolauncher import cleanup
from WebHostLib.models import Room, Seed, Slot
from . import TestBase
class TestCleanup(TestBase):
def test_cleanup_unowned(self) -> None:
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=UUID(int=0))
Room(id=uuid4(), owner=UUID(int=0), seed=s1)
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4()) # Owned
Room(id=uuid4(), owner=UUID(int=0), seed=s2) # Unowned room of owned seed
Seed(id=uuid4(), multidata=b"", owner=UUID(int=0)) # Unowned seed with no rooms
commit()
cleanup({"ROOM_AUTO_DELETE": 0})
with db_session:
self.assertEqual(Room.select().count(), 0) # Both rooms were unowned
self.assertEqual(Seed.select().count(), 1) # s2 is owned
self.assertIsNotNone(Seed.get(id=s2.id))
def test_cleanup_auto_delete(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
recent_time = now - timedelta(days=2)
with db_session:
# Case 1: Old room, owned
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
# Case 2: Recent room, owned
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r2 = Room(id=uuid4(), owner=uuid4(), seed=s2, last_activity=recent_time)
# Case 3: Old seed, no rooms, owned
s3 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
# Case 4: Recent seed, no rooms, owned
s4 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=recent_time)
# Case 5: Old seed with recent room (should not be deleted)
s5 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r5 = Room(id=uuid4(), owner=uuid4(), seed=s5, last_activity=recent_time)
commit()
# Delete items older than 5 days
cleanup({"ROOM_AUTO_DELETE": 5})
with db_session:
self.assertIsNone(Room.get(id=r1.id), "Old room should be deleted")
self.assertIsNotNone(Room.get(id=r2.id), "Recent room should NOT be deleted")
self.assertIsNone(Seed.get(id=s3.id), "Old seed without rooms should be deleted")
self.assertIsNotNone(Seed.get(id=s4.id), "Recent seed without rooms should NOT be deleted")
self.assertIsNotNone(Seed.get(id=s5.id), "Old seed with recent room should NOT be deleted")
self.assertIsNotNone(Room.get(id=r5.id), "Recent room for old seed should NOT be deleted")
# Seeds are deleted if they have NO rooms AND are old.
# After r1 is deleted, s1 has no rooms. Since it's old, it should be deleted.
self.assertIsNone(Seed.get(id=s1.id), "Old seed whose only room was deleted should be deleted")
def test_cleanup_disabled(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
commit()
cleanup({"ROOM_AUTO_DELETE": 0})
with db_session:
self.assertIsNotNone(Room.get(id=r1.id), "Room should NOT be deleted when auto-delete is 0")
self.assertIsNotNone(Seed.get(id=s1.id), "Seed should NOT be deleted when auto-delete is 0")
def test_cleanup_slots(self) -> None:
now = utcnow()
old_time = now - timedelta(days=10)
with db_session:
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
slot1 = Slot(player_id=1, player_name="P1", seed=s1, game="TestGame")
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=now)
slot2 = Slot(player_id=2, player_name="P2", seed=s2, game="TestGame")
commit()
# Delete items older than 5 days
cleanup({"ROOM_AUTO_DELETE": 5})
with db_session:
self.assertIsNone(Seed.get(id=s1.id), "Old seed should be deleted")
self.assertIsNone(Slot.get(id=slot1.id), "Slot of deleted seed should be deleted")
self.assertIsNotNone(Seed.get(id=s2.id), "Recent seed should NOT be deleted")
self.assertIsNotNone(Slot.get(id=slot2.id), "Slot of recent seed should NOT be deleted")
+4 -2
View File
@@ -4,9 +4,11 @@ from worlds.AutoWorld import AutoWorldRegister
class TestWebDescriptions(unittest.TestCase):
world_relevant = True
def test_item_descriptions_have_valid_names(self) -> None:
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.web.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
@@ -15,7 +17,7 @@ class TestWebDescriptions(unittest.TestCase):
def test_location_descriptions_have_valid_names(self) -> None:
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.web.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
+4 -2
View File
@@ -9,12 +9,14 @@ from worlds.AutoWorld import AutoWorldRegister
class TestDocs(unittest.TestCase):
world_relevant = True
@classmethod
def setUpClass(cls) -> None:
WebHost.copy_tutorials_files_to_static()
def test_has_tutorial(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
with self.subTest(game_name):
tutorials = world_type.web.tutorials
@@ -29,7 +31,7 @@ class TestDocs(unittest.TestCase):
)
def test_has_game_info(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
if not world_type.hidden:
safe_name = secure_filename(game_name)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
+2
View File
@@ -7,6 +7,8 @@ import WebHost
class TestFileGeneration(unittest.TestCase):
world_relevant = True
@classmethod
def setUpClass(cls) -> None:
cls.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
+4 -2
View File
@@ -6,9 +6,11 @@ from Options import OptionCounter, NamedRange, NumericOption, OptionList, Option
class TestOptionPresets(unittest.TestCase):
world_relevant = True
def test_option_presets_have_valid_options(self):
"""Test that all predefined option presets are valid options."""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
@@ -38,7 +40,7 @@ class TestOptionPresets(unittest.TestCase):
"""Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
value.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():

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