Compare commits

...

264 Commits

Author SHA1 Message Date
7971961166 add schedule I, sonic 1/frontiers/heroes, spirit island 2026-04-02 23:46:36 -07:00
9246bd9541 merge upstream 2026-03-18 16:58:13 -07: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
Silvris
b372b02273 OptionCreator: 0.6.6 reported issues (#5949) 2026-03-04 19:47:30 +01:00
black-sliver
f26313367e MultiServer: graceful shutdown for ctrl+c and sigterm (#5996) 2026-03-04 00:02:12 +01:00
Fabian Dill
a3e8f69909 Core: introduce finalize_multiworld and pre_output stages (#5700)
Co-authored-by: Ishigh1 <bonjour940@yahoo.fr>
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-01 17:53:41 +01:00
Fabian Dill
922c7fe86a Core: allow async def functions as commands (#5859) 2026-03-01 17:51:56 +01:00
Duck
e49ba2ff6f Undertale: Use check_locations helper to avoid redundant sends (#5993) 2026-03-01 01:30:26 +01:00
Doug Hoskisson
61d5120f66 Core: use typing_extensions deprecated (#5989) 2026-03-01 00:14:33 +01:00
Chris W
ff5402c410 Fix(undertale): prevent massive bounce msg spam for position updates (#5990)
* fix(undertale): prevent massive bounce msg spam for position updates

* make sure player is removed on leaving / timing out

* do not check for tags: online, as bounce evaluation is or'd
2026-02-28 22:56:28 +00:00
black-sliver
fcccbfca65 MultiServer: don't keep multidata alive for race_mode (#5980) 2026-02-26 18:31:39 +00:00
black-sliver
2db5435474 CI: upgrade InnoSetup to 6.7.0 (#5979) 2026-02-26 10:34:23 +01:00
Aaron Wagener
eeb022fa0c The Messenger: minor maintenance (#5965) 2026-02-26 02:24:50 +01:00
Duck
b30b2ecb07 Return new state man (Vi's note: I have chosen not to change this title) (#5978) 2026-02-25 20:52:34 +01:00
DrAwesome4333
699ca8adf6 WebHost: Add CORS headers to API Endpoints (#5777) 2026-02-25 02:47:54 +01:00
Silvris
fefd790de6 ALTTP: remove world: MultiWorld and typing (#5974) 2026-02-24 18:43:42 +01:00
30fa0658b0 remove attestation from docker workflow
I don't know what this is or what value this adds, so removing it for now as it doesn't work on gitea
2026-02-23 19:04:15 -08:00
44a0c44036 update docker.yml to create and publish a docker image to dockerhub 2026-02-23 18:54:19 -08:00
Fabian Dill
d83da1b818 WebHost: memory leak fixes (#5966) 2026-02-22 21:22:22 +01:00
Mysteryem
0de09cd794 Core: Better scaling explicit indirect conditions (#4582)
* Core: Better scaling explicit indirect conditions

When the number of connections to retry was large and `queue` was large
`new_entrance not in queue` would get slow.

For the average supported world, the difference this change makes is
negligible.

For a game like Blasphemous, with a lot of explicit indirect conditions,
generation of 10 template Blasphemous yamls with
`--skip_output --seed 1` and progression balancing disabled went from
19.0s to 17.9s (5.9% reduction in generation duration).

* Create a new variable for the new set created from the intersection
2026-02-21 15:16:57 +01:00
Ixrec
48c201af19 Docs: Replace the 'true filler' weasel words in adding games.md's mention of get_filler_item_name() (#5958)
* replace the 'true filler' weasel words with a clear term defined by the linked method's docstring

* Update docs/adding games.md

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-02-20 21:43:34 +01:00
BroOtti
b0300d3063 Factorio: Update Download Image in guides (#5953) 2026-02-19 22:46:23 +01:00
Mysteryem
e0e34894a3 HK: Fix cached filler item names persisting between generations (#5950)
HK's `get_filler_item_name` was writing lists into a ClassVar[dict] on
the `HKWorld` class. This dict would not be cleaned out between
generations on the same process, leaving behind cached data from
previous generations.

I confirmed the issue when running single-slot generations on a local
webhost, where `self.cached_filler_items` could be already populated
during `HKWorld.__init__()`.

This has been fixed by putting an individual cache list on each HKWorld
instance, instead of a shared cached on the class.
2026-02-19 20:50:13 +01:00
Mysteryem
18e3a8911f Saving Princess: Fix each slot sharing the same music_table (#5952)
`music_table` was initialized on the `SavingPrincessWorld` *class*, so
was being shared by each Saving Princess slot in the multiworld.

This has been fixed by initializing the `music_table` attribute on each
`SavingPrincessWorld` *instance* in `generate_early()` instead.
2026-02-19 20:13:54 +01:00
Ian Robinson
c505b1c32c Core: Add missing args to rule builder inits (#5912)
* add filtered_resolution to inits

* update from_dict calls too
2026-02-18 22:40:16 +01:00
Silvris
e22e434258 Options: support "random" and variations for OptionSet with defined valid_keys (#4418)
* seemingly works? needs testing

* attempt docs update

* move to verify resolution (keep?)

* account for no valid keys and "random" being passed

* Update advanced_settings_en.md

* Update Options.py

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

* Update Options.py

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

* unify random handling between range and set

* Update Options.py

* Update Options.py

* Update Options.py

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

* super is weird

* fix item/location

* remove groups from options

* unittest

* pep8

* Update Options.py

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

* Update Options.py

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2026-02-18 21:16:04 +01:00
Ian Robinson
8b91f9ff72 Rule Builder: Make region.connect and add_event support rule builder (#5933)
* make region.connect and add_event support rule builder

* fix test

* oops fix

* update tests and typing

* rm unused
2026-02-18 20:57:05 +01:00
PoryGone
fadcfbdfea Celeste (Open World): v1.0.7 Logic Fixes (#5827)
### Logic Fixes:
- Old Site A
  - Logic now allows for going backwards from the `Awake` checkpoint
- Golden Ridge A
  - `Golden Strawberry` now requires `Moving Platforms` as it should
- Mirror Temple A
  - `Room b-01c Strawberry` and `Room b-10 Strawberry` no longer erroneously require `Red Boosters`
  - `Golden Strawberry` now requires `Dash Refills` as it should
- Reflection A
  - Logic now allows for going backwards from the `Reflection` checkpoint
- Reflection B
  - Logic now allows for going backwards from the `Reflection` checkpoint
- Farewell
  - `Power Source Key 2` now logically requires `Dash Switches` and `Double Dash Refills` as it should
2026-02-18 19:06:11 +01:00
qwint
3c4c294f9c WebHost: Better document config loading fallback (#5948)
* change functionality to follow comment

* revert code change and explicitly document intent
2026-02-18 17:51:58 +00:00
Silvris
27a7e538df Launcher: run init_logging before importing from worlds (#5402) 2026-02-15 23:48:53 +01:00
Katelyn Gigante
cb0cadcc5f core: If a user specifies --no-gui, don't show GUI messageboxes (#5514)
* move `gui_enabled` to Utils

* docstring

* If a user specified no-gui, don't use GUI messageboxes

---------

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
2026-02-15 20:22:37 +01:00
black-sliver
2e1035a29f Doc: running from source and building on Linux (#5881)
* CI: make the comment in 'Build' more verbose

* Doc: add Linux running from source and build instructions

* Doc: fix name in running from source on Linux

* Update docs/running from source.md

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

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2026-02-15 19:10:34 +00:00
Silvris
21c7f3cd92 Launcher: generate templates for option presets (#5062) 2026-02-15 19:22:40 +01:00
Louis M
13b6a5f4b2 Aquaria: Adding a lots of options and one check (#4414)
First, there is one check that has been added. The location is "Sitting on the throne before the cathedral with the crest on it" and the item is the "Opening of the Cathedral door". In Vanilla, sitting on the crested throne open the door to the cathedral.

Now for the options added:
- infinite_hot_soup: Make the game impossible to run out of hot soup once you got it as an item.
- open_body_tongue: The body level (the ending level) is blocked by a big tongue. This option remove the tongue without having to go to the Sunken City (where it is normally removed)
- maximum_ingredient_amount: In the Vanilla game, the ingredients and dishes count is limited to 8. This option make this count configurable.
- skip_final_boss_3rd_form: The final boss has 5 forms. The 3rd one is long and not really challenging. So, this option is used to skip this form.
- save_healing: Normally, the save points heal the player. There is also beds in the game that can heal the player. This option removed the healing from the save point and forced the player to heal using beds (or healing monsters or healing items)
- no_progression_(whatever): Make this "whatever" (generally regions) exempt of progression items. Note that this is not using the exclusion-feature of AP, as these locations may still contain 'Useful' items. It is only guaranteed that no 'Progression' and 'Progression_Skip_Balancing'-items will appear in these regions. This option does not remove locations. I did not exclude or completely remove the regions because I don't have enough location to put every useful item in the game.

There is also 2 new goals:
- Four gods: The goal is obtained when the player beat the four gods (this is something like half the game). Useful to have quicker runs
- Gods and Creator: Like the Four Gods run, but when the four gods are obtained, that open a transportation turtle to the final boss (the Creator) and the player have to beat the final boss to obtain the goal.

Note that for the 2 new goals, all locations from the last 4 areas (Abyss, Frozen Veil, Sunken City and The Body) are completely removed (not just excluded).
2026-02-15 19:20:45 +01:00
Fabian Dill
78e8082a6f CommonClient: actually close the UI on /exit (#5860) 2026-02-15 18:39:35 +01:00
agilbert1412
1de91fab67 Stardew Valley: 7.x.x - The Jojapocalypse Update (#5432)
Major Content update for Stardew Valley

### Features
- New BundleRandomization Value: Meme Bundles - Over 100 custom bundles, designed to be jokes, references, trolls, etc
- New Setting: Bundles Per Room modifier
- New Setting: Backpack Size
- New Setting: Secretsanity - Checks for triggering easter eggs and secrets
- New Setting: Moviesanity - Checks for watching movies and sharing snacks with Villagers
- New Setting: Eatsanity - Checks for eating items
- New Setting: Hatsanity - Checks for wearing Hats
- New Setting: Start Without - Allows you to select any combination of various "starting" items, that you will actually not start with. Notably, tools, backpack slots, Day5 unlocks, etc.
- New Setting: Allowed Filler Items - Allows you to customize the filler items you'll get
- New Setting: Endgame Locations - Checks for various expensive endgame tasks and purchases
- New Shipsanity value: Crops and Fish
- New Settings: Jojapocalypse and settings to customize it
- Bundle Plando: Replaced with BundleWhitelist and BundleBlacklist, for more customization freedom
- Added a couple of Host.yaml settings to help hosts allow or ban specific difficult settings that could cause problems if the people don't know what they are signing up for.

Plus a truckload of improvements on the mod side, not seen in this PR.

### Removed features
- Integration for Stardew Valley Expanded. It is simply disabled, the code is all still there, but I'm extremely tired of providing tech support for it, plus Stardew Valley 1.7 was announced and that will break it again, so I'm done. When a maintainer steps up, it can be re-enabled.
2026-02-15 18:02:21 +01:00
Scipio Wright
4ef5436559 TUNIC: Depriority for some items (#5589) 2026-02-15 17:47:40 +01:00
josephwhite
f2a6a769b0 Webhost: Fix defaults for NamedRange and TextChoice (#5139) 2026-02-15 17:46:40 +01:00
NewSoupVi
8a767bd2ad APQuest: Improve the auto-generated .gitignore for data/sounds (#5670)
I didn't quite think this through: In this specific case, you want the gitignore to also ignore itself, since it itself is an auto-generated file.
2026-02-14 00:35:12 +01:00
CodeGorilla
7df243b860 Utils: Improvements to visualize_regions for debugging GER usage (#4685)
* Improvements to visualize_regions for debugging GER usage

- allow the user to pass in a dict[int, int] to visualize_regions that maps Entrance.randomization_group to a color in RGB form. This allows for better visualization of which dangling entrances should match, or which matching groups are not being correctly respected.
- do full region visualization for unreached regions, so that entrances that could connect to new regions can be visualized.
- visualize unconnected entrances on regions, in addition to connected and unconnected exits, so that available ER targets can be visualized as well

* Add detail_disconnected_regions parameter to visualize_regions

* Rename detail_disconnected_regions to detail_other_regions for consistency

* Add auto_assign_colors param to visualize_regions

* Make auto assignment of entrance colors deterministic

* Assume show_other_regions is true if detail_other_regions is true

* Remove unused random import

* whitespace adjustments

* Move overflow check to prevent potential infinite loop

It wasn't exactly likely, as the user would have had to manually define all 4096 colors and then need an additional color on top of that, but accounting for that kind of nonsense is easy enough in this case.

* positive condition

---------

Co-authored-by: CodeGorilla <3672561+Ars-Ignis@users.noreply.github.com>
2026-02-08 18:39:49 +01:00
earthor1
f35d91933b Core: Throw OptionError for option type Toggle in certain scenarios (#5874)
* Throw OptionError for option type Toggle in certain scenarios

* Adding missing space to Options.py

Co-authored-by: Katelyn Gigante <clockwork.singularity@gmail.com>

---------

Co-authored-by: Katelyn Gigante <clockwork.singularity@gmail.com>
2026-02-08 18:34:12 +01:00
Ian Robinson
286769a0f3 Core: Add rule builder (#5048)
* initial commit of rules engine

* implement most of the stuff

* add docs and fill out rest of the functionality

* add in explain functions

* dedupe items and add more docs

* pr feedback and optimization updates

* Self is not in typing on 3.10

* fix test

* Update docs/rule builder.md

Co-authored-by: BadMagic100 <dempsey.sean@outlook.com>

* pr feedback

* love it when CI gives me different results than local

* add composition with bitwise and and or

* strongly typed option filtering

* skip resolving location parent region

* update docs

* update typing and add decorator

* add string explains

* move simplify code to world

* add wrapper rule

* I may need to abandon the generic typing

* missing space for faris

* fix hashing for resolved rules

* thank u typing extensions ilu

* remove bad cacheable check

* add decorator to assign hash and rule name

* more type crimes...

* region access rules are now cached

* break compatibility so new features work

* update docs

* replace decorators with __init_subclass__

* ok now the frozen dataclass is automatic

* one more type fix for the road

* small fixes and caching tests

* play nicer with tests

* ok actually fix the tests

* add item_mapping for faris

* add more state helpers as rules

* fix has from list rules

* fix can reach location caching and add set completion condition

* fix can reach entrance caching

* implement HasGroup and HasGroupUnique

* add more tests and fix some bugs

* Add name arg to create_entrance

Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>

* fix json dumping option filters

* restructure and test serialization

* add prop to disable caching

* switch to __call__ and revert access_rule changes

* update docs and make edge cases match

* ruff has lured me into a false sense of security

* also unused

* fix disabling caching

* move filter function to filter class

* add more docs

* tests for explain functions

* Update docs/rule builder.md

Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>

* chore: Strip out uses of TYPE_CHECKING as much as possible

* chore: add empty webworld for test

* chore: optimize rule evaluations

* remove getattr from hot code paths

* testing new cache flags

* only clear cache for rules cached as false in collect

* update test for new behaviour

* do not have rules inherit from each other

* update docs on caching

* fix name of attribute

* make explain messages more colorful

* fix issue with combining rules with different options

* add convenience functions for filtering

* use an operator with higher precedence

* name conflicts less with optionfilter

* move simplify and instance caching code

* update docs

* kill resolve_rule

* kill true_rule and false_rule

* move helpers to base classes

* update docs

* I really should finish all of my

* fix test

* rename mixin

* fix typos

* refactor rule builder into folder for better imports

* update docs

* do not dupe collectionrule

* docs review feedback

* missed a file

* remove rule_caching_enabled from base World

* update docs on caching

* shuffle around some docs

* use option instead of option.value

* add in operator and more testing

* rm World = object

* test fixes

* move cache to logic mixin

* keep test rule builder world out of global registry

* todone

* call register_dependencies automatically

* move register deps call to call_single

* add filtered_resolution

* allow bool opts on filters

* fix serialization tests

* allow reverse operations

---------

Co-authored-by: BadMagic100 <dempsey.sean@outlook.com>
Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>
2026-02-08 17:00:23 +01:00
black-sliver
1dd91ec85b Core, Tests: allow Archipelago items in all worlds (#5893) 2026-02-05 08:56:25 +01:00
dependabot[bot]
6adeb8b95e SC2: Bump protobuf from 6.31.1 to 6.33.5 in /worlds/_sc2common (#5890)
Bumps [protobuf](https://github.com/protocolbuffers/protobuf) from 6.31.1 to 6.33.5.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-version: 6.33.5
  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-02-05 07:37:09 +01:00
ScorelessPine
3e0d42bf9e Core: Add SDL_MOUSE_FOCUS_CLICKTHROUGH=1 environment variable to kvui (#5804)
This variable should fix the 'double click' required when trying to interact with buttons on an unfocused window
2026-02-05 01:32:25 +01:00
James White
41e22dabda KH2: Add SuperBosses, Cups, AtlanticaToggle and SummonLevelLocationToggle to slot data (#5708)
* Add SuperBosses, Cups and AtlanticaToggle

* Add SummonLevelLocationToggle
2026-02-05 01:14:35 +01:00
PIEisFANTASTIC
39e7ee315e KH2: Add a new "CasualBounties" Setting (#4877)
* KH2: casual bounties option

* Casual Bounty: Adjust level bounty logic to correspond with max level check setting

* Bugfix: We have one less possible bounty with corresponding level bounty logic

* Casual Bounty: Move option to better spot

* Bugfix: Prevent possible .remove() crash

* Revert "Bugfix: We have one less possible bounty with corresponding level bounty logic"

This reverts commit 3c929e00db.

* Bugfix: Typo in conditional

* Casual Bounties: Remove Scar, add MCP

I knew I was missing one second visit fight and Scar shouldn't be there he's a first visit

* Casual Bounties: Add some clarity to the CasualBounty setting

* Docs: Update docs to reflect new CasualBounty setting

* KH2: Add bounty locations as location groups

Feedback on this needed, trying to do this to make it work with the code above the additions made it so the game generated 1 less item than locations, despite linking properly
It does function as intended though

* KH2: Update docs
2026-02-05 01:11:40 +01:00
jamesbrq
3e032e6cd6 MLSS: Add Manifest + Minor Bugfixes (#5728)
* Remove outdated header change for ROM verification

* Update Connections to be compatible with python ver. 3.8

* Update inno_setup.iss

* Update inno_setup.iss

* Merge branch 'main' of https://github.com/jamesbrq/ArchipelagoMainMLSS

* Add Manifest + Minor Bugfixes

* Even further safeguards for Oho Oasis Temples

* Update basepatch.bsdiff
2026-02-05 00:58:34 +01:00
mechanicset
609f4af600 Satisfactory: Update Universal Tracker Method for FinalElevatorPhase Option (#5812) 2026-02-05 00:44:40 +01:00
Jonathan Tan
4c27e35445 TWW: Support launcher command line arguments (#5806)
* Support launcher command line arguments

* Use `launch` instead of `launch_subprocess`

* Remove old runner code
2026-02-05 00:41:48 +01:00
soopercool101
b0c967c039 Docs, SM64: Remove outdated FAQ item (#5887)
It is no longer possible to connect to a multiworld game on a version of the client with this bug, as all versions with this bug report AP v0.3.5 or less
2026-02-05 00:36:26 +01:00
GreenMarco
c51da00bfb Docs: add spanish language for MLSS (#5172)
* Docs: add spanish language for SM64

* Docs: add spanish language for MLSS

* Update worlds/mlss/docs/setup_es.md

Co-authored-by: PantoUwUr <99690102+PantoUwUr@users.noreply.github.com>

* Update worlds/mlss/docs/es_Mario & Luigi Superstar Saga.md

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

---------

Co-authored-by: PantoUwUr <99690102+PantoUwUr@users.noreply.github.com>
Co-authored-by: RoobyRoo <thegreenrobby@gmail.com>
2026-02-05 00:26:56 +01:00
massimilianodelliubaldini
f3389f5d8b Jak and Daxter: Replace Pymem, Add Linux Support (#5850)
* Replace pymem with PyMemoryEditor (nonworking)

* Add back pymem for faster windows address searching.

* Replace other uses of pymem, parameterize executable names.

* Updated to add linux and potential MacOS support to launching gk and … (#84)

* Updated to add linux and potential MacOS support to launching gk and goalc. Still needs tested on MacOS.

* Switched to using x-terminal-emulator instead of trying to find gnome-terminal or konsole

Made argument building for suprocessing goalc easier to read

Fixed OS X support to use osascript instead of attempting to run Terminal directly

* Changed Terminal usage to use Archipelago's Launh utility, which handles terminal launching for me for both linux and OS X

* Added try/except to re-connect the memory process. The process file/id changes over time on linux, and this works to re-connect without needing to restart

* Removed Unsetting  env var in favor of reporting to the source authors

* Putting PyMemoryEditor local. (#85)

* Putting PyMemoryEditor local

---------

Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>

* Fixing minor problems (#87)

* Refactor away circular launcher import.

* Push latest PyMemoryEditor scan utility (#91)

Co-authored-by: Louis M <Louis M>

* Remove Pymem, rely solely on PyMemoryEditor. Add konsole support.

* Jak 1: Remove vendored copy of PME, update imports, requirements, and manifest.

* Jak 1: Prevent server connect until game is properly setup.

* Jak 1: reduce REPL/Compiler confusion, small updates to setup guide.

* Write hack for Konsole on AppImage to avoid OpenSSL error.

* Refactor LD_LIBRARY_PATH hack.

* Update worlds/jakanddaxter/agents/memory_reader.py

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

* Update worlds/jakanddaxter/agents/memory_reader.py

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

---------

Co-authored-by: Morgan <morgan07kelley@gmail.com>
Co-authored-by: Louis M <prog@tioui.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-02-04 18:45:09 +01:00
Duck
3b1971be66 Core: Fix some typing errors (#4995)
* Fix some type errors in Generate and Options

* Add type parameter to new hint and fix whitespace errors

* Update identifier style
2026-02-02 19:55:57 +01:00
black-sliver
4cb518930c Fix, OptionsCreator: export options on Linux (#5774)
* Core/Utils: Use correct env for save_filename from AppImage

* OptionsCreator: run export on a separate thread

Running a blocking call from kivy misbehaves on Linux.
This also changes '*.yaml' to '.yaml' for Utils.save_filename,
which is the correct way to call it.

* Core/Utils: destroy Tk root after save/open_filename

This allows using those functions from multiple threads.
Note that pure Tk apps should not use those functions from Utils.

* OptionsCreator: show snack when save_filename fails

* OptionsCreator: disable window while exporting

* OptionsCreator: fixing typing of added stuff
2026-02-01 22:23:14 +01:00
Omnises Nihilis
c835bff570 Docs: KH1 more troubleshooting and clearer nomenclature (#5872)
* updated kh1 docs

* second pass

* tweak

* Update worlds/kh1/docs/kh1_en.md

Co-authored-by: Flit <8645405+FlitPix@users.noreply.github.com>

* Update worlds/kh1/docs/kh1_en.md

Co-authored-by: Flit <8645405+FlitPix@users.noreply.github.com>

* Update worlds/kh1/docs/kh1_en.md

Co-authored-by: Flit <8645405+FlitPix@users.noreply.github.com>

* semicolon

---------

Co-authored-by: Flit <8645405+FlitPix@users.noreply.github.com>
2026-02-01 18:22:28 +01:00
Natalie Weizenbaum
6ee02fc62d Docs (DS3): Fix the documentation for the Simple Early Bosses option (#5856)
* Docs (DS3): Fix the documentation for the Simple Early Bosses option

This option changed in the client a while ago, but we forgot to update
the server.

* Update Options.py
2026-02-01 12:06:16 +01:00
Jacob Lewis
8095f922bc [WebHost Docs] Updated and clarified new tracker endpoitns and misc fixes. (#5475)
* Adding json/python to codeblocks to make it pretty, fixed spelling mistakes, swapped uuids for suuids in the examples, and expanded on /tracker and /static_tracker, and /slot_data_tracker giving the details of the API calls endpoints

* Add in API Cacheing timers and related text blurb

* updated for merged edit to /static_tracker

* Removed timer from /datapackage/checksum
2026-01-31 20:19:46 +01:00
CookieCat
77e5f3733e AHIT: Add option to shuffle Battle of the Birds director tokens and time bonus pickups (#5400) 2026-01-31 20:09:31 +01:00
Rosalie
c47687dd21 TLOZ: Move completion condition to be before set_rules is complete (#5391) 2026-01-31 20:08:40 +01:00
Exempt-Medic
8662433142 FFMQ: Fix Collect/Remove Asymmetry (#5253) 2026-01-31 20:05:43 +01:00
PlatanoBailando
5f073c2a76 Doc: Reword required python version for AP (#5822)
Many many people have read this and then installed 3.14, so it clearly needs rewording.
2026-01-31 13:49:54 +01:00
Duck
c5d67dd97a Docs: Explain building a single world with Build APWorlds component (#5879) 2026-01-31 13:30:59 +01:00
black-sliver
9b421450b1 Doc: WebHost: update readme and style guide (#4853)
* Doc: WebHost living standard

* Docs: update style guide for HTML, CSS and JS

* Unblame phar

* Too many words

* The better choice

* More rules

* Removed too much

* Docs: add recommendations for script defer and async
2026-01-28 20:57:12 +01:00
JaredWeakStrike
a6740e7be3 KH2: Deathlink and ingame item popups (#5206)
---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Delilah <lindsaydiane@gmail.com>
2026-01-28 07:10:29 +01:00
Duck
65ef35f1b4 Core: Give clearer error message for invalid .apworld zip (#5871)
* Update messages and check

* Make "official" error message show up for 3.14

* Add zip error handling

* Small cleanups
2026-01-27 22:48:50 +01:00
Duck
520253e762 ModuleUpdate: Add explicit error when above max supported version (#5868)
* Update messages and check

* Make "official" error message show up for 3.14
2026-01-26 18:36:41 +00:00
threeandthreee
aa3614a32b LADX: fix improved additional warps (#5858) 2026-01-23 07:48:33 +01:00
Will Morrow
94492c45cb Super Mario 64: Add painting passability as items (#5294) 2026-01-21 15:12:53 +01:00
NewSoupVi
8f261bb27c Core: Add Pymem to requirements.txt (#5855)
As to not break custom worlds when Jak & Daxter moves from PyMem to PyMemoryEditor
2026-01-20 21:37:17 +01:00
Nicholas Saylor
ddd08342c8 Docs: Show that Data is optional for bounces #5794 2026-01-20 20:24:30 +01:00
Ixrec
c7db213ee9 Docs: explicitly document why get_filler_item_name may return non-IC.filler items, despite its name (#5747)
* Docs: explicitly document why get_filler_item_name may return non-IC.filler items, despite its name

* reword

* apply Scipio's rewordings

* Update worlds/AutoWorld.py

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

* any

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2026-01-20 20:18:18 +01:00
Ixrec
220248dd3d Docs: define and explain the trade-off of "local" vs "remote" items (#5718)
* first draft

* second draft

* fix indentation of bullet point wrapped lines

* move quote

* explicitly discuss all three item handling flags, since the start inventory one is easily forgotten

* rewrite to avoid a 'debate between two camps' framing

* tweak the wording to allow for the possibility that some games can 'just' do both local and remote items without exposing this detail to the player

* relative links
2026-01-20 20:15:00 +01:00
Ixrec
5932160f15 Docs: add dev FAQ for 'should I start with the APWorld or the client?' (#5716)
* Docs: add dev FAQ for 'should I start with the APWorld or the client?'

* fix indentation of bullet point wrapped lines

* use %20 for spaces in links

* link to adding games.md and add #ap-modding-help to adding games.md

* make APQuest a link

* also linkify 'run a local server'

* reword the 'judging client is easier' point to reflect a broader range of first-timers

* move the 'not 100%' point into the introductory sentences, and tweak related wording

* correct link
2026-01-20 20:14:43 +01:00
black-sliver
76e0619b79 Core: Bump version from 0.6.6 to 0.6.7 (#5851) 2026-01-20 00:06:57 +01:00
Fabian Dill
646a52a2e7 LADX: no pickle (#5849) 2026-01-19 21:28:25 +01:00
NewSoupVi
e1322df8b0 APQuest: Explain game_name and supports_uri more in components.py (#5759)
* APQuest: Explain game_name and supports_uri more in components.py

Hopefully this can lead to more games implementing support for the "click on slot name -> everything launches automatically" functionality.

* Update components.py

* Update components.py
2026-01-19 21:26:20 +01:00
Doug Hoskisson
092a9dc6bd Core: fix bug with missing help text (#5632)
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-01-19 20:13:43 +01:00
Phaneros
9f71fe707f SC2: fix supreme logic hole (#5768)
* sc2: Fixing a discrepancy between slot data and logic
where story tech would not be granted for supreme if zerg was not a selected race.

* sc2: Fixed an issue where Kinetic Blast was not listed as a vanilla Kerrigan ability

* sc2: Fixing some functions that could force Kerrigan items into the pool when playing Kerriganless

* sc2: excluding zerg excludes hots for vanilla-like mission order
* Preprocessing options
* Moving general empty selection handling to option preprocessing
* Adding a unit test for empty race/campaign selection

* sc2: Properly handling non-raceswapped campaigns when excluding campaigns based on race exclusions

* sc2: Adding an explicit error message if a user excludes all missions in a way with no obvious resolution
2026-01-19 20:11:31 +01:00
wildham
b8311a62e7 FFMQ: Update link to upstream rando (#5838) 2026-01-19 20:10:00 +01:00
Colin
13830ff4cb Timespinner: Align Lantern Logic (#5562) 2026-01-19 03:44:26 +01:00
Duck
c1b858b2cf Core: Add .apignore format to not include files in APWorld Builder (#5779) 2026-01-18 17:45:12 +01:00
Mysteryem
a035ac579c Noita: Fix filling Shop Item locations without updating item.location (#5840)
In single-player multiworlds with small item pools, Noita was manually
placing some items into Shop Item locations, but was only setting
location.item, and not also setting item.location so that the item and
location refer to one another.

This has been fixed by using the MultiWorld.push_item() helper method to
place the items instead of manually placing the items.
2026-01-18 14:47:55 +01:00
Duck
20c10e33c4 Shapez: Change image links to relative (#5803) 2026-01-18 14:46:51 +01:00
Duck
a4e4ce1c72 Core: Change image link to relative (#5802) 2026-01-18 14:45:41 +01:00
Scipio Wright
983936af8c TUNIC: Fix region for the grass by the West Garden portal (#5784) 2026-01-18 14:44:32 +01:00
Scrungip
62dfeac441 Super Mario Land 2: Fix Goal Logic (#5781) 2026-01-18 14:43:30 +01:00
Mysteryem
b81e1a228a The Messenger: Fix lambda capture issue in add_closed_portal_reqs (#5816) 2026-01-18 14:42:50 +01:00
lepideble
5899920e48 Factorio: fix inverted condition in victory requirements (#5647) 2026-01-18 14:33:52 +01:00
black-sliver
8dee460397 customserver: don't set last_activity that will be overwritten later (#5844) 2026-01-17 13:46:20 +01:00
Remy Jette
cda54e0bea WebHost: Fix world sorting in /tutorial/ (#5785) 2026-01-15 22:21:44 +01:00
Rob B
0554bf4e2d Satisfactory: Fix typo in GoalSelection possible values description comment (#5826) 2026-01-15 22:20:09 +01:00
Mysteryem
b92803e77f Core: replace the eval in OptionsCreator.py (#5828) 2026-01-15 22:19:13 +01:00
James White
69e83071ff Multiserver: remove dead code (#5831) 2026-01-11 16:54:12 +01:00
Benny D
875765e6dc PyCharm: Fix name of apworld builder run config (#5824)
* rename the apworld builder run config

* Update Build APWorlds.run.xml

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2026-01-09 10:24:37 +01:00
NewSoupVi
db56e26df9 Core: Make .apworlds importable using importlib (without force-importing them first) (#5734)
* Make apworlds importable in general

* move it to a probably more appropriate place?

* oops
2026-01-05 22:54:02 +01:00
Duck
5a88641228 Docs: Make image path in contributing absolute (#5790) 2025-12-25 12:59:32 +01:00
Ian Robinson
16559e7595 Core: allow abstract world classes (#5468) 2025-12-24 14:48:05 +01:00
NewSoupVi
d594d5d4a7 APQuest: Fix import shadowing issue (#5769)
* Fix import shadowing issue

* another comment
2025-12-22 15:32:52 +01:00
MarioManTAW
e950a2fa58 Paint: Add manifest (#5778)
* Paint: Implement New Game

* Add docstring

* Remove unnecessary self.multiworld references

* Implement start_inventory_from_pool

* Convert logic to use LogicMixin

* Add location_exists_with_options function to deduplicate code

* Simplify starting tool creation

* Add Paint to supported games list

* Increment version to 0.4.1

* Update docs to include color selection features

* Fix world attribute definitions

* Fix linting errors

* De-duplicate lists of traps

* Move LogicMixin to __init__.py

* 0.5.0 features - adjustable canvas size increment, updated similarity metric

* Fix OptionError formatting

* Create OptionError when generating single-player game with error-prone settings

* Increment version to 0.5.1

* Update CODEOWNERS

* Update documentation for 0.5.2 client changes

* Simplify region creation

* Add comments describing logic

* Remove unnecessary f-strings

* Remove unused import

* Refactor rules to location class

* Remove unnecessary self.multiworld references

* Update logic to correctly match client-side item caps

* Paint: Add manifest

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-12-22 04:08:52 +01:00
Ixrec
1df38cb782 Docs: explicitly document why 2^53-1 is the max size, not ^31 or ^63 (#5717)
* explicitly document why 2^53-1 is the max size, not ^31 or ^63

* explicitly recommend 32-bit ids

* make description correct by explicitly mentioning and linking to a description of 'safe'
2025-12-20 23:19:42 +01:00
Benjamin S Wolf
c6400b6673 Core: Process all player files before reporting errors (#4039)
* Process all player files before reporting errors

Full tracebacks will still be in the console and in the logs, but this creates a relatively compact summary at the bottom.

* Include full typename in output

* Update module access and address style comments

* Annotate variables

* multi-errors: Revert to while loop

* Core: Handle each roll in its own try-catch

* multi-errors: Updated style and comments

* Undo accidental index change

* multi-errors: fix last remaining ref to erargs
2025-12-20 23:06:32 +01:00
Mysteryem
dbf2325c01 KH2: Fix placing single items onto multiple locations in pre_fill (#5619)
`goofy_pre_fill` and `donald_pre_fill` would pick a random `Item` from a
`list[Item]` and then use `list.remove()` to remove the picked `Item`,
but the lists (at least `donald_weapon_abilities`) could contain
multiple items with the same name, so `list.remove()` could remove a
different `Item` to the picked `Item`, allowing an `Item` in the list to
be picked and placed more than once.

This happens because `Item.__eq__` only compares the item's `.name` and
`.player`, and `list.remove()` compares by equality, meaning it can
remove a different, but equal, instance from the list.

This results in `old_location.item` not being cleared, so
`old_location.item` and `new_location.item` would refer to the same
item.
2025-12-20 22:32:12 +01:00
PinkSwitch
dd5b25399a Yoshi's Island - Fix some small logic issues that were reported, add json file (#5742)
* Fix Piece of Luigi not goaling until reset

* Update .gitignore

* fix logic thing that one guy said

* fix platform being missing from chomp rock zone rules

* add json file

* added the wrong one

* remove extraneous lnk

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-12-20 13:36:20 +01:00
Mysteryem
8178ee4e58 Satisfactory: Fix nondeterministic creation of trap filler items (#5766)
The `trap_selection_override` option is an `OptionSet` subclass, so its `.value` is a `set`.

Sets have nondeterministic iteration order (the iteration order depends on the hashes of the objects within the set, which can change depending on the random hashseed of the Python process).

This `.enabled_traps` is used in `Items.get_filler_item_name()` with `random.choice(self.enabled_traps)`, which is called as part of creating the item pool in `Items.build_item_pool()` (for clarity, this `random` is the world's `Random` instance passed as an argument, so no problems there). So, with `self.enabled_traps` being in a nondeterministic order, the picked trap to add to the item pool through `random.choice(self.enabled_traps)` would be nondeterministic.

Sorting the `trap_selection_override.value` before converting to a `tuple` ensures that the names in `.enabled_traps` are always in a deterministic order.

This issue was identified by merging the main branch into the PR branch for https://github.com/ArchipelagoMW/Archipelago/pull/4410 and seeing Satisfactory fail the tests for hash-determinism. With this fix applied, the tests in that PR pass.
2025-12-19 23:25:20 +01:00
Jarno
ad1b41ea81 Satisfactory/Timespinner: Added Manifesto (#5764)
* Added Manifesto

* Update archipelago.json

* Update archipelago.json

* Update archipelago.json

---------

Co-authored-by: Jarno <jarno.westhof@gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-12-19 21:00:36 +01:00
Jarno
efd8528db0 MultiServer: Safe DataStorage .pop (#5060)
* Make datastorage .pop not throw on missing key or index

* Reworked to use logic rather than exception catching
2025-12-19 14:57:10 +01:00
Silvris
e54a15978f Celeste Open World: speedup module load (#5448)
* speedup world load

* those 3 weren't in-fact needed
2025-12-19 14:54:41 +01:00
Duck
d78b9ded2d Core: Add datapackage exports to gitignore (#5719)
* Gitignore and description

* Update description
2025-12-19 14:53:56 +01:00
Duck
53e8130c9c Yugioh: Add space in concatenated string (#5695)
* Add spaces

* Revert wrong one

* Add right one
2025-12-19 14:53:24 +01:00
PinkSwitch
55c70a5ba8 EarthBound: Implement New Game (#5159)
* Add the world

* doc update

* docs

* Fix Blast/Missile not clearing Reflect

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/__init__.py

remove unused import

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

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/modules/dungeon_er.py

make bool optional

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

* Update worlds/earthbound/modules/boss_shuffle.py

typing update

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

* Update worlds/earthbound/modules/boss_shuffle.py

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

* Filter events out of item name to id

* we call it a glorp

* Update worlds/earthbound/Regions.py

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

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/Items.py

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

* Update worlds/earthbound/Regions.py

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

* Fix missing optional import

* hint stuff

* -Fix Apple Kid text being wrong
-Fix Slimy Pile text being wrong

* -Fix some sprite corruption if PSI was used when an enemy loaded another enemy
-Fixed a visible artifact tile during some cutscenes

* Update ver

* Update docs

* Fix some money scripting issues

* Add argument to PSI fakeout attack

* Updated monkey caves shop description

* Remove closing markdown from doc

* Add new flavors

* Make flavors actually work

* Update platforms

* Fix common gear getting duplicated

* Split region initialization

* Condense checks for start inventory + some other junk

* Fix some item groups - change receiver phone to warp pad

* wow that one was really bad :glorp:

* blah

* Fix cutoff option text

* switch start inventory concatenation to itertools

* Fix sky runner scripting bug - added some new comm suggestions

* Fix crash when generating with spoiler_only

* Fix happy-happy teleport not unlocking after beating carpainter

* Hint man hints can now use CreateHint packets to create hints in other games

* Adjust some filler rarity

* Update world to use CreateHints and deprecate old method

* Fix epilogue skip being offset

* Rearrange a couple regions

* Fix tendapants getting deleted in battle

* update doc

* i got scared and forgot i had multiple none checks and am worried about this triggering but tested and it works

* Fix mostly typing errors from silvris

* More type checks

* More typing

* Typema

* Type

* Fix enemy levels overwriting music

* Fix gihugic blunder

* Fix Lumine Hall enabling OSS

* del world

* Rel 4.2.7

* Remove some debug logs

* Fix vanilla bug with weird ambush detection

* Fix Starman Junior having an unscaled Freeze

* Change shop scaling

* Fix shops using the wrong thankful script

* Update some bosses in boss shuffle

* Loc group adjustment

* Update some boss shuffle stuff | Fix Enemizer attacks getting overwritten by Shuffle data | Fix flunkies not updating and still being used with enemizer

* Get rid of some debug stuff

* Get boss shuffle running, dont merge

* Fix json and get boss shuffle no plando back up

* Fix Magicant Boost not initializing to Ness if party count = 4

* Fix belch shop using wrong logic

* Don't re-send goal status

* EBitem

* remove :

* idk if this is whatvi wanted

* All client messagesnow only send when relevant instead of constantly

* Patch up the rest of boss plando

* Fix Giygas being not excluded from enemizer

* Fix epilogue again

* adjust the sphere scaling name

* add the things

* Fix Ness being placed onto monotoli when monotoli was in sea of eden

* Fix prefill properly

* Fix boss shuffle on vanilla slots.

* rename this, apparently

* Update archipelago.json

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-12-19 14:52:27 +01:00
Jarno
ebbdd7bfda Satisfactory: Add New Game (#5190)
* Added Satisfactory to latest master

* Fixed hard drive from containing the mam + incremented default value for harddrive progression

* Apply cherry pick of 3076259

* Apply cherry pick of 6114a55

* Clarify Point goal behavior (https://github.com/Jarno458/SatisfactoryArchipelagoMod/issues/98)

* Update Setup guide and info page

* Add links to Gifting and Energy Link compatible games. Add info on Hard Drive behavior

* Fix typos

* Update hard drive behavior description

* Hopefully fixed the mam from getting placed behind harddrives

* Add 1 "Bundle: Solid Biofuel" to default starting items (for later chainsaw usage or early power gen)

* Add info/warning about save setup failure bug

* Add notes about dedicated server setup

* Fixes: `TypeError: 'set' object is not subscriptable`

random.choice does not work over set objects, cast to a list to allow 'trap_selection_override'

* progrees i think

* Fixed some bugs

* Progress commmit incase my pc crashes

* progress i think as test passed

* I guess test pass, game still unbeatable tho

* its generating

* Some refactorings

* Fixed generation with different elevator tiers

* Remove debug statement

* Fix this link.

* Implemented abstract base classes + some fixes

* Implemented many many new options

* Yay more stuff

* Fixed renaming of filters

* Added 1.1 stuffs

* Added options groups and presets

* Fixes after variable renmame

* Added recipy groups for easyer hinting

* Implemented random Tier 0

* Updated slot_data

* Latest update for 1.1

* Applied cheaper building costs of assembler and foundry

* Implemented exploration cost in slot_data

* Fixed exposing option type

* Add goal time estimates

* Trap info

* Added support for Universal Tracker
Put more things in the never exclude pool for a more familiar gameplay

* Added iron ore to build hub

* Added Dark Matter Crystals

* Added Single Dark Matter Crystals

* Fixed typo in options preset

* Update setup directions and info

* Options formatting fixes, lower minimum ExplorationCollectableCount, add new Explorer starting inventory items preset

* Fixed incorrect description on the options

* Reduce Portable Miner and Reinforced Iron Plate quantities in "Skip Tutorial Inspired" starting preset

* Fixed options pickling error

* Reworked logic to no longer include Single: items as filler
Reworked logic for more performance
Reworked logic to always put useful equipment in pool

* Fixed Itemlinks
Removed space elevator parts from fillers
Removed more AWESOME shop purchaseables from minimal item pool
Added all equipment to minimal item pool
Removed non fissile and fertile uranium from minimal item pool
Removed portal from minimal item pool
Removed Ionized fuel from minimal item pool
Removed recipes for Hoverpack and Turbo Rifle Ammo from minimal item pool
Lowered the chance for rolling steel on randomized starter recipes

* Fixed hub milestone item leaking to into wrong milestones

* Fixed unlock cost of geothermal generator

* Fixed itemlinks again

* Add troubleshooting note about hoverpacks

* Add starting inventory bundle delivery info

* Added hint generation at generation time
Harddrive locations now go from 1-100 rather then 0-99

* Update __init__.py

Fixed mistake

* Cleaned docs to be better suited to get verified

* Update CODEOWNERS

Added Satisfactory

* Update README.md

Added Satisfactory

* Restructure and expand setup page to instruct both players and hosts

* Add terms entry for Archipelago mod

* Fixed generation of traps

* Added Robb as code owner

* Restore tests to original state

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix additional typos from code review

* Implemented fix for itterating enum flags on python 3.10

* Update en_Satisfactory.md

* Update setup_en.md

* Apply suggestions from code review

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* more world > multiworld

* Clarify universal tracker behavior

* Fix typos

* Info on smart hinting system

* Move list of additional mods to a page on the mod GitHub

* Restore revamped setup guide that other commits  overwrote
Originally from be26511205, d8bd1aaf04

* Removed bundle of ficsit coupons from the from the item pool
added estimated completion times to space elevator option description

* Apply suggestions from code review

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Wording

* Fix typo

* Update with changes from ToBeVerified branch

* Update note about gameplay options

* Update note about gameplay options

* Improved universal tracker handling

* Improved universal tracker + modernized code a bit

* Fixed bugs that where re-introduced

* Added Recipe: Excited Photonic Matter

* Removed python 3.9 workaround

* Fixed

* Apply suggestions from code review

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Streamlined handle craftable logic by using itterable rather then tuple
Removed dict.keys as the dict itzelf already enumerates over keys

* Updated option description

* Fixed typing

* More info on goal completion conditions

* More info on goal completion conditions (093fe38b6e)

* Apply suggestions from code review

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>

* Implemented review results

* PEP8 stuff

* More PEP8

* Rename ElevatorTier->ElevatorPhase and related for clarity and consistency.
Untested

* speedups part1

* speedsups on part rules

* Fix formatting

* fix `Elevator Tier #` string literals missed in rename

* Remove unused/duplicate imports + organize imports, `== None` to `is None`

* Fixed after merge

* Updated values + removed TODO

* PEPed up the code

* Small refactorings

* Updated name slot data to phase

* Fix hint creation

* Clarify wording of elevator goal

* Review result

* Fixed minor typo in option

* Update option time estimates

---------

Co-authored-by: Rob B <computerguy440+gh@gmail.com>
Co-authored-by: ProverbialPennance <36955346+ProverbialPennance@users.noreply.github.com>
Co-authored-by: Joe Amenta <airbreather@linux.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-12-19 14:48:03 +01:00
Silent
863f161466 TUNIC: Update wording on Mask and Lantern option descriptions #5760 2025-12-19 14:42:05 +01:00
Silent
9305ecb3bc TUNIC: Update world version to 4.2.7 #5761 2025-12-19 14:41:37 +01:00
Scipio Wright
0002bb8e5a TUNIC: Make UT care about hex goal amount #5762 2025-12-19 14:11:29 +01:00
Alchav
b42fb77451 Factorio: Craftsanity (#5529) 2025-12-18 07:52:15 +01:00
Ziktofel
5a8e166289 SC2: New maintainership (#5752)
I (Ziktofel) stepped down but will remain as a mentor
2025-12-18 00:06:49 +01:00
Rosalie
5fa719143c TLOZ: Add manifest file (#5755)
* Added manifest file.

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-12-18 00:06:06 +01:00
Duck
a906f139c3 APQuest: Fix ValueError on typing numbers/backspace #5757 2025-12-18 00:02:11 +01:00
Katelyn Gigante
56363ea7e7 OptionsCreator: Respect World.hidden flag (#5754) 2025-12-17 20:09:35 +01:00
Fabian Dill
01e1e1fe11 WebHost: increase form upload limit (#5756) 2025-12-17 19:12:10 +01:00
Fabian Dill
4477dc7a66 Core: Bump version from 0.6.5 to 0.6.6 (#5753) 2025-12-17 03:33:29 +01:00
Silvris
45994e344e Tests: test that every option in a preset is visible in either simple or complex UI (#5750) 2025-12-16 19:27:02 +01:00
Silvris
51d5e1afae Launcher: fix shortcuts on the AppImage (#5726)
* fix appimage executable reference

* adjust working dir

* use argv0 instead of appimage directly

* set noexe on frozen
2025-12-15 03:30:07 +01:00
Ziktofel
577b958c4d SC2: Fix Kerrigan logic for active spells (#5746) 2025-12-15 00:56:54 +01:00
Benny D
ce38d8ced6 Docs: Add 'silasary' to Mac tutorial contributors (#5745) 2025-12-14 17:01:32 +01:00
BeeFox-sys
d65fcf286d Launcher: Add workaround for kivy bug for linux touchpad devices (#5737)
* add code to fix touchpad on linux, courtesy of Snu of the kivy community

* Launcher: Update workaround to follow styleguide
2025-12-12 02:44:22 +01:00
Phaneros
5a6a0b37d6 sc2: Fixing typos in item descriptions (#5739) 2025-12-11 22:43:06 +01:00
Fabian Dill
4a0a65d604 WebHost: add played game to static tracker (#5731) 2025-12-09 00:45:02 +01:00
Emily
d25abfc305 Docs: update apsudoku docs / add links to web build (#5720) 2025-12-05 01:09:56 +01:00
Duck
0905e3ce32 WebHost/Game Guides: Change links to stay on current instance (#5699)
* Remove absolute links to archipelago.gg

* Fix other link issues
2025-12-02 00:40:05 +01:00
black-sliver
ac84b272c5 CI: update appimage runtime to fix problems with sleep (#5706)
also updates appimagetool.
Old tool should be compatible, but there are 2 bug fixes in it.
2025-12-01 01:25:06 +01:00
Phaneros
e8a63abfa4 weights: Fixing negatives and zeroes disappearing from option dicts updated by triggers (#5677) 2025-11-30 13:36:36 +01:00
Silvris
3fa2745c37 OptionCreator: pre-RC1 fixes (#5680)
* fix str default on text choice

* fix range with default random

* forgot module update

* handle namedrange default special

* handle option group of options we should not render

* Update OptionsCreator.py

* Update OptionsCreator.py

* grammar
2025-11-30 01:23:13 +01:00
Doug Hoskisson
775065715d SNIClient: new SnesReader interface (#5155)
* SNIClient: new SnesReader interface

* fix Python 3.8 compatibility
`bisect_right`

* move to worlds
because we don't have good separation importable modules and entry points

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

* pass context to `read` so that we can have a static instance of `SnesReader`

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

- `dataclass` instead of `NamedTuple` for `Read`
- comprehension in `SnesData.__init__`
- `slots` for dataclasses

* update chunk size to 2048
2025-11-30 01:22:35 +01:00
Doug Hoskisson
4e608b13ae Docs: fix name of "Build APWorlds" component (#5703) 2025-11-30 01:18:11 +01:00
Colin
886cc68051 Timespinner: Exclude Removed Location from Web Tracker (#5701) 2025-11-29 19:13:43 +01:00
Ziktofel
146a314d22 SC2: Update Infested Banshee description to be more clear when the Burrow is unlocked #5685 2025-11-29 19:12:29 +01:00
Phaneros
18cf1bce36 sc2: Item group fixes and new item groups (#5679)
* sc2: Fixing missing buildings in Terran buildings group; adding sc1 and melee unit groups

* sc2: Removing out-of-place comment
2025-11-29 19:12:04 +01:00
wildham
f7e3f4e589 [FFMQ] Bugfix: Fix missing logic rule for Frozen Fields > Aquaria access 2025-11-29 19:11:07 +01:00
BlastSlimey
9f9765b78d shapez: Fix logic bug with vanilla shapes and floating layers #5623 2025-11-29 19:10:37 +01:00
Scipio Wright
8ae1a7da32 TUNIC: Fix fuse rule in lower zig #5621 2025-11-29 19:09:55 +01:00
Mysteryem
08ea3fe225 ALTTP: Fix setting Beat Agahnim 1 event twice (#5617)
alttp was setting the `Beat Agahnim 1` event onto the `Agahnim 1` location twice.

I was debugging a multiworld generation issue with various custom worlds, where, for debugging purposes, I changed `multiworld.push_item` to make it crash like `location.place_locked_item` when the location was already filled, which also identified this minor issue in alttp.
2025-11-29 19:09:30 +01:00
massimilianodelliubaldini
b81be6b4fc Jak and Daxter: Second attempt at fixing trade tests. #5599 2025-11-29 19:08:39 +01:00
LiquidCat64
f1aca0fc46 CVCotM: Add a client safeguard in case the player doesn't have Dash Boots #5500 2025-11-29 19:07:02 +01:00
Exempt-Medic
d88fe99780 DS3: Update/Fix Excluded Locations Logging (#5220)
* DS3: Fix Excluded Locations in Spoiler Log

* Update __init__.py

* update wording

* Comment out failing code
2025-11-29 19:04:07 +01:00
Carter Hesterman
360a1384f2 Civ6: Fix issue with names including civ-breaking characters (#5204) 2025-11-29 19:02:15 +01:00
Duck
d089b00ad5 Core: Add spaces in concatenated strings #5697 2025-11-29 18:52:08 +01:00
Duck
c05a2adc38 Wargroove: Add space in concatenated string #5696 2025-11-29 18:51:20 +01:00
Duck
7631242621 MLSS: Add space in concatenated string #5694 2025-11-29 18:50:34 +01:00
Duck
df48c3e718 KH1: Add space in concatenated string #5693 2025-11-29 18:48:46 +01:00
Duck
9a755e64b2 Jak and Daxter: Add space in concatenated string #5692 2025-11-29 18:48:23 +01:00
Duck
34d362a003 CV64/CVCotM: Add spaces in concatenated strings (#5691)
* Possible space removal

* Add spaces

* Missed one

* Revert removals, use newline
2025-11-29 18:47:54 +01:00
Duck
b75cce5d41 TLOZ: Add space in concatenated string #5690 2025-11-29 18:47:17 +01:00
threeandthreee
a07faca2d9 LADX: catch exception after closing magpie #5687 2025-11-29 18:46:22 +01:00
Phaneros
8a1a715dc4 SC2: logic fixes minor bugs (#5660)
* Pulsars no longer count as basic anti-air for protoss.
  * This is in response to player feedback that they were just too weak DPS-wise
* Haven's Fall (P) logic loosened slightly.
  * Void rays are now a one-unit solution to the rule
  * Scouts are now considered a one-unit solution to the rule
  * Two-unit solutions are now considered standard rather than advanced
  * Caladrius is now listed as an anti-muta unit for the two-unit solutions
  * This was discussed in the #SC2-dev channel.
    * Snarky did some testing and found that void rays were barely any worse than destroyers at handling mutas, and destroyers are already listed as a one-unit solution.
    * Snarky also found that scouts could mostly solo the mission at low skill level
    * Note that this rule only applies to the "beating the infestations" part of the mission; there are additional requirements for beating it, including a competent comp.
* The Host (T) now also can use SoA abilities if SoA presence is set to `any_race_lotv`, not just `everywhere`
2025-11-29 01:46:41 +01:00
Duck
60a192b1b6 ALttP/Factorio: Add spaces in concatenated strings (#5564)
* Add them

* Revert "Add them"

This reverts commit 82be86191f.

* Re-add ALttP/Factorio
2025-11-27 19:51:23 +01:00
NewSoupVi
3b721e0365 Tests: Move hosting test to APQuest #5671 2025-11-26 20:55:35 +01:00
Benny D
3e16c20fce PyCharm: fix the apworld builder run config (#5678)
* fix the apworld builder pycharm runner

* Update Build APWorld.run.xml

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-11-26 12:45:12 +01:00
Emerassi
ec2c39e82f Docs: Improve the documentation for priority locations to mention de-prioritized (#5631)
* Update the descriptions for priority and exclude locations to be more clear.

* Revision on priority

* Moved my change over to the documentation instead of the generated yaml comment.

* update per vi feedback

* Trying a 2 sentence approach

* more details!

* Update options api.md

* Update options api.md
2025-11-26 01:00:25 +01:00
NewSoupVi
23d319247f APQuest: Fix import of Protocol from bokeh instead of typing (#5674)
* APQuest: Fix import of Protocol from bokeh instead of typing

* bump world version
2025-11-25 23:45:55 +01:00
Fabian Dill
c2c488410f Core: Fix typo in docstring for hint_points in commonclient (#5673) 2025-11-25 22:40:57 +01:00
Fabian Dill
8ea49e76db Core: updates of requirements (#5672) 2025-11-25 22:40:32 +01:00
Phaneros
d834ecec6a SC2: Fix bugs and issues around excluded/unexcluded (#5644) 2025-11-25 20:44:07 +01:00
threeandthreee
f3000a89d4 LADX: Give better feedback during patching (#5401) 2025-11-25 20:42:55 +01:00
qwint
aa2774a5d5 Tests: Move world dependencies in tests to APQuest #5668 2025-11-25 19:26:37 +01:00
NewSoupVi
f9630fa13b Core: Add a bunch of validation to AutoPatchRegister (#5431)
* Add a bunch of validation to AutoPatchRegister

* slightly change it

* lmao
2025-11-25 00:38:42 +01:00
NewSoupVi
e0cbf77dae APQuest: Implement New Game (#5393)
* APQuest

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

* You can kinda play it now! Now we just need to render the game... :)))

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

* don't need to clear checked_locations, but do need to still clear finished_game

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

* lmao that already exists I don't need to do it myself

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

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

* Update worlds/apquest/client/custom_views.py

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

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

* Add math trap to game (only works in play_in_console mode so far)

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

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

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

* Be more clear about why we're overriding Item and Location

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

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

* a word on the ambiguity of the word 'filler'

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

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

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

* Add some more words to worlds/apqeust/options.py

* more words in options.py

* 120 chars (thanks Ixrec >:((( LOL)

* Less confusing wording about rules, hopefully?

* victory -> completion

* remove the immediate creation of the hammer rule on the option region entrance

* access rule performance

* Make all imports module-level in world.py

* formatting

* get rid of noqa RUF012 (and also disable the rule in my local ruff.toml

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

* pass mypy again (I don't love this one but all the alternatives are equally bad)

* subclass instead of override

* I forgor to remove these

* Get rid of classvar_matrix and instead talk about some other stuff

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

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

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

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

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-11-25 00:38:06 +01:00
threeandthreee
447f8fba20 LADX: switch to asyncio.get_running_loop() (#5666) 2025-11-24 22:42:31 +01:00
Ziktofel
e60ea1765c SC2: Migrate external resources from user repos to sc2 organization (#5653) 2025-11-24 20:29:41 +01:00
Ziktofel
2d15c23681 SC2: Fix missing brackets in Zerg The Host logic (#5657)
* SC2: Fix missing brackets in Zerg The Host logic

* Allow usage of SoA any race LotV and add additional brackets
2025-11-23 19:37:50 +01:00
Jonathan Tan
c2f76d81ab TWW: Fix client sending duplicate magic meter (#5664) 2025-11-23 01:38:19 +01:00
Benjamin S Wolf
8b737cad21 Core: Better error message for invalid range values (#4038) 2025-11-22 02:55:04 +01:00
Spineraks
fd968d749e Yacht Dice: Add archipelago.json manifest #5658 2025-11-21 23:33:51 +01:00
Andres
32a021096b Factorio: Add connection change filtering functionality (#4997) 2025-11-21 02:30:11 +01:00
Silvris
3c819ec781 LttP: logic fixes and missing bombs (#5645)
3 logic issues:
* #3046 made it so that prizes were included in LttP's pre_fill items. It accounted for it in regular pre_fill, but missed stage_pre_fill.
* LttP defines a maximum number of heart pieces and heart containers logically within each difficulty. Item condensing did not account for this, and could reduce the number of heart pieces below the required amount logically. Notably, this makes some combination of settings much harder to generate, so another solution may end up ideal.
* Current logic rules do not properly account for the case of standard start and enemizer, requiring a large amount of items logically within a short number of locations. However, the behavior of Enemizer in this situation is well-defined, as the guards during the standard starting sequence are not changed. Thus the required items can be safely minimized.
2025-11-16 06:57:47 +01:00
Katelyn Gigante
01e64a2b69 Doc: Update Mac instructions to instruct the user to make a frozen bundle (#5614) 2025-11-16 02:04:23 +00:00
black-sliver
5e08c8bd98 Celeste (Open World): fix tutorial link on game page (#5627) 2025-11-15 16:57:50 +00:00
josephwhite
24aa4af7c2 WebHost: Validation for webworld themes (#5083) 2025-11-15 16:55:13 +01:00
NewSoupVi
b3c323ede3 The Witness: Fix CreateHints spoiling vague hints (#5359)
* Encode non-local vague hints as negative player number

* comments

* also bump req client version
2025-11-15 16:22:56 +01:00
NewSoupVi
3ec1e9184b Core: Only error in playthrough generation if game is not beatable (#5430)
* Core: Only error in playthrough generation if game is not beatable

The current flow of accessibility works like this:

```
if fulfills_accessibility fails:
    if multiworld can be beaten:
        log a warning
    else:
        raise Exception

if playthrough is enabled:
    if any progression items are not reachable:
        raise Exception
```

This means that if you do a generation where the game is beatable but some full players' items are not reachable, it doesn't crash on accessibility check, but then crashes on playthrough. This means that **whether it crashes depends on whether you have playthrough enabled or not**.

Imo, erroring on something accessibility-related is outside of the scope of create_playthrough. Create_playthrough only needs to care about whether it can fulfill its own goal - Building a minimal playthrough to everyone's victory.
The actual accessibility check should take care of the accessibility.

* Reword

* Simplify sentence
2025-11-15 03:38:33 +01:00
NewSoupVi
5055f87034 The Witness: Add archipelago.json (#5481)
* Add archipelago.json to witness

* Update archipelago.json
2025-11-15 03:36:53 +01:00
Dinopony
3bb43b266f Landstalker: Add manifest file (#5629) 2025-11-15 03:27:41 +01:00
Justus Lind
c2094a9fc4 Muse Dash: Update Song list to Medium5 Echoes (#5597) 2025-11-15 03:26:20 +01:00
Silvris
b82878130c Core: add random range and additional random descriptions to template yaml (#5586) 2025-11-15 03:10:23 +01:00
Silvris
8fbd3569ce Core: add a local yaml creator GUI (#4900)
Adds a GUI for the creation of simple yamls (no weighting) locally.
2025-11-15 02:49:59 +01:00
Katelyn Gigante
494381b272 Factorio: Add no-enemies mode to worldgen schema (#5542) 2025-11-15 02:46:35 +01:00
Ziktofel
7422b10a3d SC2: Fix the goal mission tooltip depending on goal missions' status (#5577) 2025-11-15 02:44:27 +01:00
threeandthreee
e4b5591582 CV64: Fix not having Clocktower Key3 when placed in a start_inventory (#5592) 2025-11-15 02:20:53 +01:00
Ziktofel
557a284afd SC2: Fix custom mission order if used in weights.yaml (#5604) 2025-11-15 02:18:58 +01:00
LiquidCat64
75eb2660ce CV64: Fix not having Clocktower Key3 when placed in a start_inventory (#5596) 2025-11-15 02:18:18 +01:00
Phaneros
34e13c5e5a SC2: Adjusting and slightly simplifying mission difficulty pool adjustment configuration (#5587) 2025-11-15 02:17:35 +01:00
Fly Hyping
d098372913 Wargroove 1: added archipelago.json (#5591) 2025-11-15 02:16:31 +01:00
Snowflav_
7e8746c01b Pokémon R/B: Specify encounter types for Dexsanity hint data (#5574) 2025-11-15 02:13:34 +01:00
Phaneros
93d3d8b084 SC2: Fixing a gap in the ascendant upgrades in the tracker (#5570) 2025-11-15 02:12:51 +01:00
Snarky
98273ddad9 SC2: Add Manifest (#5559) 2025-11-15 02:12:15 +01:00
threeandthreee
c408c53598 LADX: create manifest (#5563) 2025-11-15 02:11:31 +01:00
Salzkorn
cde73c5a2b SC2: Move race_swap pick_one functionality to mission picking (#5538) 2025-11-15 02:10:35 +01:00
Phaneros
d7eb95a2ee SC2: Allowing unexcluded_items to affect items excluded by vanilla_items_only (#5520) 2025-11-15 02:09:57 +01:00
Mysteryem
a2f8877810 Core: Fix #5605 - Trigger values being shared by weights.yaml slots (#5636)
The "+" and "-" trigger operations modify sets/lists in-place, but
triggers could set a value to the same set/list for multiple slots using
weights.yaml.

This fix deep-copies all values set from new (trigger) weights to ensure
that the values do not get shared across multiple slots.
2025-11-14 21:58:44 +01:00
NewSoupVi
5779dda937 Core: Deprecate Utils.get_options by July 31st, 2025 (#4811)
* 0.4.4 lol

* Pycharm pls

* Violet pls

* Remove OptionsType

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-11-14 15:58:06 +01:00
Omnises Nihilis
d597bc40a2 Docs: Add troubleshooting section to kh1_en.md, typo fix in kh1/Options.py (#5615)
* kh1 docs update

* small grammar

* suggested fix

client is die (sadge)

* h2/h3 -> ##/###

* oops that's my bad
2025-11-14 08:28:55 +00:00
black-sliver
4a41550cad CI: update pytest to 9.0.1 (#5637) 2025-11-14 08:26:34 +00:00
Doug Hoskisson
e4fd06482e Core: don't use union type just to reuse a name (#5246)
This is the "followup PR" to address these comments:
https://github.com/ArchipelagoMW/Archipelago/pull/5071#discussion_r2117417408

It's better to have a different name for different representations of the data, so that someone reading the code doesn't need to wonder whether it has gone through the transformation or not.
2025-11-12 12:35:19 +01:00
NewSoupVi
dba03e3a76 Choo Choo Charles: Raise InvalidItemError instead of bare Exception (#5429) 2025-11-12 12:33:26 +01:00
black-sliver
4b2298e168 SC2: make worlds._sc2common.bot.proto a regular package (#5626)
This is currently required for import reasons
and has a test that fails without it.
2025-11-11 23:19:20 +01:00
black-sliver
283badfc7e SoE: add apworld manifest (#5557)
* SoE: add APWorld manifest

* SoE: small typing fixes
2025-11-11 18:16:38 +00:00
GreenestBeen
088f2cc269 SC2: Remove dependency on s2clientprotocol and update protobuf version (#5474) 2025-11-11 18:58:20 +01:00
massimilianodelliubaldini
ea40156194 Jak 1: Remove PAL-only instructions, no longer needed. (#5598) 2025-11-11 16:05:43 +00:00
Adrian Priestley
0bf48d7a1b fix(workflows): Update branch filter in Docker workflow (#5616)
* fix(workflows): Update branch filter in Docker workflow
- Change branch filter from wildcard to 'main'
- Ensures that the workflow only triggers on the main branch
2025-11-10 00:41:16 +01:00
Gurglemurgle
14f261b1dd Launcher: add skip_open_folder arg to Generate Template Options (#5302) 2025-11-10 00:31:43 +01:00
Ziktofel
bec625621a SC2 Tracker: Fix bundled Protoss W/A upgrade display (#5612) 2025-11-09 22:45:55 +01:00
Duck
19db58907a Game Docs: Fix main setup guide links (#5603) 2025-11-09 19:55:37 +00:00
Fabian Dill
77808d3ae9 Core: Bump version from 0.6.4 to 0.6.5 (#5607) 2025-11-09 03:07:47 +01:00
Fabian Dill
b2b0d15add Core: add export_datapackage tool (#5609) 2025-11-09 03:07:23 +01:00
Vertraic
ecadb301c0 Core: Allows Meta.yaml to add triggers to individual yaml's categories. (#3556)
* Initial commit

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

* Update Generate.py

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

* change to an elif to avoid unnecessary nesting

---------

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

* Fix editorial issues in documentations

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

* added in non-common-client version clarification

* Update docs/network protocol.md

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

---------

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

* oop
2025-10-26 11:40:21 +01:00
LiquidCat64
3f139f2efb CV64: Fix Explosive DeathLink not working with Increase Shimmy Speed on #5523 2025-10-26 11:39:14 +01:00
Subsourian
41a62a1a9e SC2: added MindHawk to credits (#5549) 2025-10-26 08:54:17 +01:00
3196 changed files with 1167901 additions and 15627 deletions

View File

@@ -51,7 +51,6 @@ EnemizerCLI/
/SNI/ /SNI/
/sni-*/ /sni-*/
/appimagetool* /appimagetool*
/host.yaml
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/

View File

@@ -2,11 +2,15 @@
"include": [ "include": [
"../BizHawkClient.py", "../BizHawkClient.py",
"../Patch.py", "../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py", "../test/param.py",
"../test/general/test_groups.py", "../test/general/test_groups.py",
"../test/general/test_helpers.py", "../test/general/test_helpers.py",
"../test/general/test_memory.py", "../test/general/test_memory.py",
"../test/general/test_names.py", "../test/general/test_names.py",
"../test/general/test_rule_builder.py",
"../test/multiworld/__init__.py", "../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py", "../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py", "../test/netutils/__init__.py",

View File

@@ -1,4 +1,5 @@
# This workflow will build a release-like distribution when manually dispatched # This workflow will build a release-like distribution when manually dispatched:
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
name: Build name: Build
@@ -23,10 +24,11 @@ env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9' APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -49,7 +51,7 @@ jobs:
run: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade choco install innosetup --version=6.7.0 --allow-downgrade
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
@@ -141,9 +143,9 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64 wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -11,144 +11,43 @@ on:
- "!.github/workflows/**" - "!.github/workflows/**"
- ".github/workflows/docker.yml" - ".github/workflows/docker.yml"
branches: branches:
- "*" - "dock-dev"
tags: tags:
- "v?[0-9]+.[0-9]+.[0-9]*" - "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch: workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs: jobs:
prepare: push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
image-name: ${{ steps.image.outputs.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set lowercase image name
id: image
run: |
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
- name: Set package name
id: package
run: |
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
type=ref,event=branch,enable={{is_not_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=nightly,enable={{is_default_branch}}
- name: Compute final tags
id: final-tags
run: |
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${{ github.ref_name }}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
# Check if latest is already in tags to avoid duplicates
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
tags+=("$full_latest")
fi
fi
fi
# Set multiline output
echo "tags<<EOF" >> $GITHUB_OUTPUT
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read
packages: write packages: write
strategy: contents: read
matrix: attestations: write
include: id-token: write
- platform: amd64
runner: ubuntu-latest
suffix: amd64
cache-scope: amd64
- platform: arm64
runner: ubuntu-24.04-arm
suffix: arm64
cache-scope: arm64
steps: steps:
- name: Checkout repository - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Log in to Docker Hub
uses: docker/setup-buildx-action@v3 uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKERHUB_USER }}
username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute suffixed tags - name: Extract metadata (tags, labels) for Docker
id: tags id: meta
run: | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}" with:
suffixed=() images: ubufugu/dockipelago
for t in "${tags[@]}"; do
suffixed+=("$t-${{ matrix.suffix }}")
done
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/${{ matrix.platform }}
push: true push: true
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ needs.prepare.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
provenance: false
manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest
run: |
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
for tag in "${tag_array[@]}"; do
docker manifest create "$tag" \
"$tag-amd64" \
"$tag-arm64"
docker manifest push "$tag"
done

View File

@@ -11,10 +11,11 @@ env:
ENEMIZER_VERSION: 7.1 ENEMIZER_VERSION: 7.1
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9' APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation permissions: # permissions required for attestation
id-token: 'write' id-token: 'write'
@@ -127,9 +128,9 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64 wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract

View File

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

7
.gitignore vendored
View File

@@ -45,7 +45,11 @@ EnemizerCLI/
/SNI/ /SNI/
/sni-*/ /sni-*/
/appimagetool* /appimagetool*
<<<<<<< Updated upstream
/VC_redist.x64.exe
/host.yaml /host.yaml
=======
>>>>>>> Stashed changes
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -63,7 +67,10 @@ Output Logs/
/installdelete.iss /installdelete.iss
/data/user.kv /data/user.kv
/datapackage /datapackage
/datapackage_export.json
/custom_worlds /custom_worlds
# stubgen output
/out/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python"> <configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" /> <module name="Archipelago" />
<option name="ENV_FILES" value="" /> <option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" /> <option name="INTERPRETER_OPTIONS" value="" />
@@ -12,8 +12,8 @@
<option name="IS_MODULE_SDK" value="true" /> <option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" /> <option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" /> <option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" /> <option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" /> <option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" /> <option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" /> <option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" /> <option name="MODULE_MODE" value="false" />

View File

@@ -8,10 +8,10 @@ import secrets
import warnings import warnings
from argparse import Namespace from argparse import Namespace
from collections import Counter, deque, defaultdict from collections import Counter, deque, defaultdict
from collections.abc import Collection, MutableSequence from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
import dataclasses import dataclasses
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -22,6 +22,7 @@ import Utils
if TYPE_CHECKING: if TYPE_CHECKING:
from entrance_rando import ERPlacementState from entrance_rando import ERPlacementState
from rule_builder.rules import Rule
from worlds import AutoWorld from worlds import AutoWorld
@@ -85,7 +86,7 @@ class MultiWorld():
local_items: Dict[int, Options.LocalItems] local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems] non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing] progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]] completion_condition: Dict[int, CollectionRule]
indirect_connections: Dict[Region, Set[Entrance]] indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations] exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations] priority_locations: Dict[int, Options.PriorityLocations]
@@ -726,6 +727,7 @@ class CollectionState():
advancements: Set[Location] advancements: Set[Location]
path: Dict[Union[Region, Entrance], PathValue] path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location] locations_checked: Set[Location]
"""Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic."""
stale: Dict[int, bool] stale: Dict[int, bool]
allow_partial_entrances: bool allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
@@ -766,7 +768,7 @@ class CollectionState():
else: else:
self._update_reachable_regions_auto_indirect_conditions(player, queue) self._update_reachable_regions_auto_indirect_conditions(player, queue)
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque): def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
reachable_regions = self.reachable_regions[player] reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player] blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items # run BFS on all connections, and keep track of those blocked by missing items
@@ -784,13 +786,16 @@ class CollectionState():
blocked_connections.update(new_region.exits) blocked_connections.update(new_region.exits)
queue.extend(new_region.exits) queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None)) self.path[new_region] = (new_region.name, self.path.get(connection, None))
self.multiworld.worlds[player].reached_region(self, new_region)
# Retry connections if the new region can unblock them # Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): entrances = self.multiworld.indirect_connections.get(new_region)
if new_entrance in blocked_connections and new_entrance not in queue: if entrances is not None:
queue.append(new_entrance) relevant_entrances = entrances.intersection(blocked_connections)
relevant_entrances.difference_update(queue)
queue.extend(relevant_entrances)
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque): def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
reachable_regions = self.reachable_regions[player] reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player] blocked_connections = self.blocked_connections[player]
new_connection: bool = True new_connection: bool = True
@@ -812,6 +817,7 @@ class CollectionState():
queue.extend(new_region.exits) queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None)) self.path[new_region] = (new_region.name, self.path.get(connection, None))
new_connection = True new_connection = True
self.multiworld.worlds[player].reached_region(self, new_region)
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region) # sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections) queue.extend(blocked_connections)
@@ -1169,13 +1175,17 @@ class CollectionState():
self.prog_items[player][item] = count self.prog_items[player][item] = count
CollectionRule = Callable[[CollectionState], bool]
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
class EntranceType(IntEnum): class EntranceType(IntEnum):
ONE_WAY = 1 ONE_WAY = 1
TWO_WAY = 2 TWO_WAY = 2
class Entrance: class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
hide_path: bool = False hide_path: bool = False
player: int player: int
name: str name: str
@@ -1362,7 +1372,7 @@ class Region:
self, self,
location_name: str, location_name: str,
item_name: str | None = None, item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None, rule: CollectionRule | Rule[Any] | None = None,
location_type: type[Location] | None = None, location_type: type[Location] | None = None,
item_type: type[Item] | None = None, item_type: type[Item] | None = None,
show_in_spoiler: bool = True, show_in_spoiler: bool = True,
@@ -1390,7 +1400,7 @@ class Region:
event_location = location_type(self.player, location_name, None, self) event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler event_location.show_in_spoiler = show_in_spoiler
if rule is not None: if rule is not None:
event_location.access_rule = rule self.multiworld.worlds[self.player].set_rule(event_location, rule)
event_item = item_type(item_name, ItemClassification.progression, None, self.player) event_item = item_type(item_name, ItemClassification.progression, None, self.player)
@@ -1401,7 +1411,7 @@ class Region:
return event_item return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None, def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance: rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
""" """
Connects this Region to another Region, placing the provided rule on the connection. Connects this Region to another Region, placing the provided rule on the connection.
@@ -1409,8 +1419,8 @@ class Region:
:param name: name of the connection being created :param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region""" :param rule: callable to determine access of this connection to go from self to the exiting_region"""
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}") exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
if rule: if rule is not None:
exit_.access_rule = rule self.multiworld.worlds[self.player].set_rule(exit_, rule)
exit_.connect(connecting_region) exit_.connect(connecting_region)
return exit_ return exit_
@@ -1435,7 +1445,7 @@ class Region:
return entrance return entrance
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]: rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
""" """
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1474,7 +1484,7 @@ class Location:
show_in_spoiler: bool = True show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item: Optional[Item] = None item: Optional[Item] = None
@@ -1551,7 +1561,7 @@ class ItemClassification(IntFlag):
skip_balancing = 0b01000 skip_balancing = 0b01000
""" should technically never occur on its own """ should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch. Item that is logically relevant, but progression balancing should not touch.
Possible reasons for why an item should not be pulled ahead by progression balancing: Possible reasons for why an item should not be pulled ahead by progression balancing:
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.) 1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """ 2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
@@ -1559,13 +1569,13 @@ class ItemClassification(IntFlag):
deprioritized = 0b10000 deprioritized = 0b10000
""" Should technically never occur on its own. """ Should technically never occur on its own.
Will not be considered for priority locations, Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations. unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location. Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """ Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001 progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression", """ Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """ these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced progression_skip_balancing = 0b01001 # only progression gets balanced
@@ -1721,9 +1731,10 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % ( logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates]) sphere_candidates])
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]): if not multiworld.has_beaten_game(state):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). ' raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
f'Something went terribly wrong here.') "Something went terribly wrong here. "
f"Unreachable progression items: {sphere_candidates}")
else: else:
self.unreachables = sphere_candidates self.unreachables = sphere_candidates
break break

17
CommonClient.py Normal file → Executable file
View File

@@ -24,7 +24,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor, mark_raw from MultiServer import CommandProcessor, mark_raw
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start from Utils import gui_enabled, Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
import ssl import ssl
@@ -35,9 +35,6 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
# without terminal, we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless @Utils.cache_argsless
def get_ssl_context(): def get_ssl_context():
@@ -65,6 +62,8 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Close connections and client""" """Close connections and client"""
if self.ctx.ui:
self.ctx.ui.stop()
self.ctx.exit_event.set() self.ctx.exit_event.set()
return True return True
@@ -323,7 +322,7 @@ class CommonContext:
hint_cost: int | None hint_cost: int | None
"""Current Hint Cost per Hint from the server""" """Current Hint Cost per Hint from the server"""
hint_points: int | None hint_points: int | None
"""Current avaliable Hint Points from the server""" """Current available Hint Points from the server"""
player_names: dict[int, str] player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)""" """Current lookup of slot number to player display name from server (includes aliases)"""
@@ -572,6 +571,10 @@ class CommonContext:
return print_json_packet.get("type", "") == "ItemSend" \ return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \ and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player) and not self.slot_concerns_self(print_json_packet["item"].player)
def is_connection_change(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out connection changes."""
return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@@ -770,7 +773,7 @@ class CommonContext:
if len(parts) == 1: if len(parts) == 1:
parts = title.split(', ', 1) parts = title.split(', ', 1)
if len(parts) > 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] title = parts[0]
# display error # display error
self._messagebox = MessageBox(title, text, error=True) self._messagebox = MessageBox(title, text, error=True)
@@ -893,6 +896,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
"May not be running Archipelago on that address or port.") "May not be running Archipelago on that address or port.")
except websockets.InvalidURI: except websockets.InvalidURI:
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)") 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: except OSError:
ctx.handle_connection_loss("Failed to connect to the multiworld server") ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception: except Exception:

View File

@@ -97,4 +97,10 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
# Ensure no runtime ModuleUpdate. # Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true ENV SKIP_REQUIREMENTS_UPDATE=true
# Port range for Archipelago rooms. I choose only ports 49152-49162
ARG MAX_PORT=49162
RUN sed -i "s/65535/${MAX_PORT}/" WebHostLib/customserver.py
EXPOSE 80
ENTRYPOINT [ "python", "WebHost.py" ] ENTRYPOINT [ "python", "WebHost.py" ]

View File

@@ -280,6 +280,7 @@ def remaining_fill(multiworld: MultiWorld,
item_to_place = itempool.pop() item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None spot_to_fill: typing.Optional[Location] = None
# going through locations in the same order as the provided `locations` argument
for i, location in enumerate(locations): for i, location in enumerate(locations):
if location_can_fill_item(location, item_to_place): if location_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content, # popping by index is faster than removing by content,

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(argv: list[str] | None = None): def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
from settings import get_settings from settings import get_settings
settings = get_settings() settings = get_settings()
defaults = settings.generator defaults = settings.generator
@@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path): if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) args.plando = PlandoOptions.from_option_string(args.plando)
return args return args
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else: else:
meta_weights = None meta_weights = None
player_id: int = 1
player_id = 1 player_files: dict[int, str] = {}
player_files = {} player_errors: list[str] = []
for file in os.scandir(args.player_files_path): for file in os.scandir(args.player_files_path):
fname = file.name fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \ if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
@@ -135,9 +135,13 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else: else:
weights_for_file.append(yaml) weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file) weights_cache[fname] = tuple(weights_for_file)
except Exception as e: except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e logging.exception(f"Exception reading weights in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())} weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
@@ -152,6 +156,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
args.multi = max(player_id - 1, args.multi) args.multi = max(player_id - 1, args.multi)
if args.multi == 0: if args.multi == 0:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise ValueError( raise ValueError(
"No individual player files found and number of players is 0. " "No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi." "Provide individual player files or specify the number of players via host.yaml or --multi."
@@ -161,6 +169,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"{seed_name} Seed {seed} with plando: {args.plando}") f"{seed_name} Seed {seed} with plando: {args.plando}")
if not weights_cache: if not weights_cache:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise Exception(f"No weights found. " raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.") f"A mix is also permitted.")
@@ -171,10 +183,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None) args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {} args.name = {}
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
if meta_weights: if meta_weights:
for category_name, category_dict in meta_weights.items(): for category_name, category_dict in meta_weights.items():
for key in category_dict: for key in category_dict:
@@ -189,50 +197,93 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else: else:
yaml[category_name][key] = option yaml[category_name][key] = option
player_path_cache = {} settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
if args.sameoptions:
for fname, yamls in weights_cache.items():
try:
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
except Exception as e:
logging.exception(f"Exception reading settings in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
# Exit early here to avoid throwing the same errors again later
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
player_path_cache: dict[int, str] = {}
for player in range(1, args.multi + 1): for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path) player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter: Counter[str] = Counter()
args.player_options = {} args.player_options = {}
player = 1 player = 1
while player <= args.multi: while player <= args.multi:
path = player_path_cache[player] path = player_path_cache[player]
if path: if not path:
player_errors.append(f'No weights specified for player {player}')
player += 1
continue
for doc_index, yaml in enumerate(weights_cache[path]):
name = yaml.get("name")
try: try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ # Use the cached settings object if it exists, otherwise roll settings within the try-catch
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path]) # Invariant: settings_cache[path] and weights_cache[path] have the same length
for settingsObject in settings: cached = settings_cache[path]
for k, v in vars(settingsObject).items(): settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
if v is not None:
try:
getattr(args, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified for k, v in vars(settings_object).items():
if player not in args.name: if v is not None:
if path == args.weights_file_path: try:
# weights file, so we need to make the name unique getattr(args, k)[player] = v
args.name[player] = f"Player{player}" except AttributeError:
else: setattr(args, k, {player: v})
# use the filename except Exception as e:
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0] raise Exception(f"Error setting {k} to {v} for player {player}") from e
args.name[player] = handle_name(args.name[player], player, name_counter)
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1
except Exception as e: except Exception as e:
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
else: f"(name: {args.name.get(player, name)})")
raise RuntimeError(f'No weights specified for player {player}') player_errors.append(
f"{len(player_errors) + 1}. "
f"File {path} document #{doc_index + 1} (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
player += 1
if len(set(name.lower() for name in args.name.values())) != len(args.name): if len(set(name.lower() for name in args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}") player_errors.append(
f"{len(player_errors) + 1}. "
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
)
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
return args, seed return args, seed
@@ -311,7 +362,7 @@ class SafeFormatter(string.Formatter):
return kwargs.get(key, "{" + key + "}") return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter[str]):
name_counter[name.lower()] += 1 name_counter[name.lower()] += 1
number = name_counter[name.lower()] number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
@@ -342,7 +393,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list): elif isinstance(new_value, list):
cleaned_value.extend(new_value) cleaned_value.extend(new_value)
elif isinstance(new_value, dict): elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value)) counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
else: else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name}," raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.") f" received {type(new_value).__name__}.")
@@ -356,13 +409,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value: for element in new_value:
cleaned_value.remove(element) cleaned_value.remove(element)
elif isinstance(new_value, dict): elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value)) counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
else: else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name}," raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.") f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value cleaned_weights[option_name] = cleaned_value
else: else:
cleaned_weights[option_name] = new_weights[option] # Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
new_options = set(cleaned_weights) - set(weights) new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights) weights.update(cleaned_weights)
if new_options: if new_options:
@@ -385,6 +443,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting: if options[option_key].supports_weighting:
return get_choice(option_key, category_dict) return get_choice(option_key, category_dict)
return category_dict[option_key] return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.") raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
@@ -440,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
return weights return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions): def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
try: try:
if option_key in game_weights: if option_key in game_weights:
if not option.supports_weighting: if not option.supports_weighting:

View File

@@ -31,6 +31,10 @@ import settings
import Utils import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename, from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path) user_path)
if __name__ == "__main__":
init_logging('Launcher')
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
@@ -75,12 +79,17 @@ def open_patch():
launch([*exe, file], component.cli) launch([*exe, file], component.cli)
def generate_yamls(): def generate_yamls(*args):
from Options import generate_yaml_templates 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") target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False) generate_yaml_templates(target, False)
open_folder(target) if not args.skip_open_folder:
open_folder(target)
def browse_files(): def browse_files():
@@ -213,12 +222,17 @@ def launch(exe, in_terminal=False):
def create_shortcut(button: Any, component: Component) -> None: def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut from pyshortcuts import make_shortcut
script = sys.argv[0] env = os.environ
wkdir = Utils.local_path() if "APPIMAGE" in env:
script = env["ARGV0"]
wkdir = None # defaults to ~ on Linux
else:
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\"" script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"), make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir) startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
button.menu.dismiss() button.menu.dismiss()
@@ -483,7 +497,6 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__': if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support() multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(

View File

@@ -207,6 +207,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
else: else:
logger.info("Progression balancing skipped.") logger.info("Progression balancing skipped.")
AutoWorld.call_all(multiworld, "finalize_multiworld")
AutoWorld.call_all(multiworld, "pre_output")
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use # we're about to output using multithreading, so we're removing the global random state to prevent accidental use
multiworld.random.passthrough = False multiworld.random.passthrough = False
@@ -326,7 +329,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere: if current_sphere:
spheres.append(dict(current_sphere)) spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData | bytes = { multidata: NetUtils.MultiData = {
"slot_data": slot_data, "slot_data": slot_data,
"slot_info": slot_info, "slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()}, "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -350,11 +353,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for key in ("slot_data", "er_hint_data"): for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key]) multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9) serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format f.write(bytes([3])) # version of format
f.write(multidata) f.write(serialized_multidata)
output_file_futures.append(pool.submit(write_multidata)) output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result(): if not check_accessibility_task.result():

View File

@@ -5,15 +5,16 @@ import multiprocessing
import warnings import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9): if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
# Official micro version updates. This should match the number in docs/running from source.md. # Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.") raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
"Official 3.11.9 through 3.13.x is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13): elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
# There are known security issues, but no easy way to install fixed versions on Windows for testing. # There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.") warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 11, 0): elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
# Other platforms may get security backports instead of micro updates, so the number is unreliable. # Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.") raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool( _skip_update = bool(

View File

@@ -21,6 +21,7 @@ import time
import typing import typing
import weakref import weakref
import zlib import zlib
from signal import SIGINT, SIGTERM, signal
import ModuleUpdate import ModuleUpdate
@@ -69,6 +70,12 @@ def remove_from_list(container, value):
def pop_from_container(container, value): def pop_from_container(container, value):
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
return container
if isinstance(container, dict) and value not in container:
return container
try: try:
container.pop(value) container.pop(value)
except ValueError: except ValueError:
@@ -490,10 +497,11 @@ class Context:
self.read_data = {} self.read_data = {}
# there might be a better place to put this. # there might be a better place to put this.
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0) race_mode = decoded_obj.get("race_mode", 0)
self.read_data["race_mode"] = lambda: race_mode
mdata_ver = decoded_obj["minimum_versions"]["server"] mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple: if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}") f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"]) self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {}) clients_ver = decoded_obj["minimum_versions"].get("clients", {})
@@ -911,12 +919,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
async def on_client_connected(ctx: Context, client: Client): async def on_client_connected(ctx: Context, client: Client):
players = []
for team, clients in ctx.clients.items():
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)} games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago") games.add("Archipelago")
await ctx.send_msgs(client, [{ await ctx.send_msgs(client, [{
@@ -1301,6 +1303,13 @@ class CommandMeta(type):
commands.update(base.commands) commands.update(base.commands)
commands.update({command_name[5:]: method for command_name, method in attrs.items() if commands.update({command_name[5:]: method for command_name, method in attrs.items() if
command_name.startswith("_cmd_")}) command_name.startswith("_cmd_")})
for command_name, method in commands.items():
# wrap async def functions so they run on default asyncio loop
if inspect.iscoroutinefunction(method):
def _wrapper(self, *args, _method=method, **kwargs):
return async_start(_method(self, *args, **kwargs))
functools.update_wrapper(_wrapper, method)
commands[command_name] = _wrapper
return super(CommandMeta, cls).__new__(cls, name, bases, attrs) return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
@@ -1364,7 +1373,10 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default argname += "=" + parameter.default
argtext += argname argtext += argname
argtext += " " argtext += " "
doctext = '\n '.join(inspect.getdoc(method).split('\n')) method_doc = inspect.getdoc(method)
if method_doc is None:
method_doc = "(missing help text)"
doctext = "\n ".join(method_doc.split("\n"))
s += f"{self.marker}{command} {argtext}\n {doctext}\n" s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s return s
@@ -2560,6 +2572,8 @@ async def console(ctx: Context):
input_text = await queue.get() input_text = await queue.get()
queue.task_done() queue.task_done()
ctx.commandprocessor(input_text) ctx.commandprocessor(input_text)
except asyncio.exceptions.CancelledError:
ctx.logger.info("ConsoleTask cancelled")
except: except:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -2726,6 +2740,26 @@ async def main(args: argparse.Namespace):
console_task = asyncio.create_task(console(ctx)) console_task = asyncio.create_task(console(ctx))
if ctx.auto_shutdown: if ctx.auto_shutdown:
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task])) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
def stop():
try:
for remove_signal in [SIGINT, SIGTERM]:
asyncio.get_event_loop().remove_signal_handler(remove_signal)
except NotImplementedError:
pass
ctx.commandprocessor._cmd_exit()
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() await ctx.exit_event.wait()
console_task.cancel() console_task.cancel()
if ctx.shutdown_task: if ctx.shutdown_task:

View File

@@ -24,6 +24,39 @@ if typing.TYPE_CHECKING:
import pathlib import pathlib
_RANDOM_OPTS = [
"random", "random-low", "random-middle", "random-high",
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
]
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
def random_weighted_range(text: str, range_start: int, range_end: int):
if text == "random-low":
return triangular(range_start, range_end, 0.0)
elif text == "random-high":
return triangular(range_start, range_end, 1.0)
elif text == "random-middle":
return triangular(range_start, range_end)
elif text == "random":
return random.randint(range_start, range_end)
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
def roll_percentage(percentage: int | float) -> bool: def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance. """Roll a percentage chance.
percentage is expected to be in range [0, 100]""" percentage is expected to be in range [0, 100]"""
@@ -417,10 +450,12 @@ class Toggle(NumericOption):
def from_text(cls, text: str) -> Toggle: def from_text(cls, text: str) -> Toggle:
if text == "random": if text == "random":
return cls(random.choice(list(cls.name_lookup))) return cls(random.choice(list(cls.name_lookup)))
elif text.lower() in {"off", "0", "false", "none", "null", "no"}: elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
return cls(0) return cls(0)
else: elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
return cls(1) return cls(1)
else:
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
@@ -523,9 +558,9 @@ class Choice(NumericOption):
class TextChoice(Choice): class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int] value: str | int
def __init__(self, value: typing.Union[str, int]): def __init__(self, value: str | int):
assert isinstance(value, str) or isinstance(value, int), \ assert isinstance(value, str) or isinstance(value, int), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'" f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value self.value = value
@@ -546,7 +581,7 @@ class TextChoice(Choice):
return cls(text) return cls(text)
@classmethod @classmethod
def get_option_name(cls, value: T) -> str: def get_option_name(cls, value: str | int) -> str:
if isinstance(value, str): if isinstance(value, str):
return value return value
return super().get_option_name(value) return super().get_option_name(value)
@@ -713,33 +748,39 @@ class Range(NumericOption):
# these are the conditions where "true" and "false" make sense # these are the conditions where "true" and "false" make sense
if text == "true": if text == "true":
return cls.from_any(cls.default) return cls.from_any(cls.default)
else: # "false" # "false"
return cls(0) return cls(0)
return cls(int(text))
try:
num = int(text)
except ValueError:
# text is not a number
# Handle conditionally acceptable values here rather than in the f-string
default = ""
truefalse = ""
if hasattr(cls, "default"):
default = ", default"
if cls.range_start == 0 and cls.default != 0:
truefalse = ", \"true\", \"false\""
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
f"<int>{default}, high, low{truefalse}, "
f"{', '.join(cls._RANDOM_OPTS)}.")
return cls(num)
@classmethod @classmethod
def weighted_range(cls, text) -> Range: def weighted_range(cls, text) -> Range:
if text == "random-low": if text.startswith("random-range-"):
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text) return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else: else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. " return cls(random_weighted_range(text, cls.range_start, cls.range_end))
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
@classmethod @classmethod
def custom_range(cls, text) -> Range: def custom_range(cls, text) -> Range:
textsplit = text.split("-") textsplit = text.split("-")
try: try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])] random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError: except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}") raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort() random_range.sort()
@@ -747,14 +788,9 @@ class Range(NumericOption):
raise Exception( raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range " f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}") f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"): if textsplit[2] in ("low", "middle", "high"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0)) return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
elif text.startswith("random-range-middle"): return cls(random_weighted_range("random", *random_range))
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
else:
return cls(random.randint(random_range[0], random_range[1]))
@classmethod @classmethod
def from_any(cls, data: typing.Any) -> Range: def from_any(cls, data: typing.Any) -> Range:
@@ -769,18 +805,6 @@ class Range(NumericOption):
def __str__(self) -> str: def __str__(self) -> str:
return str(self.value) return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range): class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {} special_range_names: typing.Dict[str, int] = {}
@@ -870,7 +894,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
def __iter__(self) -> typing.Iterator[typing.Any]: def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__() return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {} default = {}
supports_weighting = False supports_weighting = False
@@ -885,7 +909,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
else: else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
def get_option_name(self, value): @classmethod
def get_option_name(cls, value):
return ", ".join(f"{key}: {v}" for key, v in value.items()) return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any: def __getitem__(self, item: str) -> typing.Any:
@@ -965,7 +990,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
def get_option_name(self, value): @classmethod
def get_option_name(cls, value):
return ", ".join(map(str, value)) return ", ".join(map(str, value))
def __contains__(self, item): def __contains__(self, item):
@@ -975,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset() default = frozenset()
supports_weighting = False supports_weighting = False
random_str: str | None
def __init__(self, value: typing.Iterable[str]): def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
self.value = set(deepcopy(value)) self.value = set(deepcopy(value))
self.random_str = random_str
super(OptionSet, self).__init__() super(OptionSet, self).__init__()
@classmethod @classmethod
def from_text(cls, text: str): def from_text(cls, text: str):
check_text = text.lower().split(",")
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
and len(check_text) == 1 and check_text[0].startswith("random")):
return cls((), check_text[0])
return cls([option.strip() for option in text.split(",")]) return cls([option.strip() for option in text.split(",")])
@classmethod @classmethod
@@ -990,7 +1022,37 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
def get_option_name(self, value): def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
if self.random_str and not self.value:
choice_list = sorted(self.valid_keys)
if self.verify_item_name:
choice_list.extend(sorted(world.item_names))
if self.verify_location_name:
choice_list.extend(sorted(world.location_names))
if self.random_str.startswith("random-range-"):
textsplit = self.random_str.split("-")
try:
random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError:
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
f"for player {player_name}")
random_range.sort()
if random_range[0] < 0 or random_range[1] > len(choice_list):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
if textsplit[2] in ("low", "middle", "high"):
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
random_range[0], random_range[1])
else:
choice_count = random_weighted_range("random", random_range[0], random_range[1])
else:
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
self.value = set(random.sample(choice_list, k=choice_count))
super(Option, self).verify(world, player_name, plando_options)
@classmethod
def get_option_name(cls, value):
return ", ".join(sorted(value)) return ", ".join(sorted(value))
def __contains__(self, item): def __contains__(self, item):
@@ -1018,6 +1080,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
supports_weighting = False supports_weighting = False
display_name = "Plando Texts" display_name = "Plando Texts"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoText]) -> None: def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value)) self.value = list(deepcopy(value))
super().__init__() super().__init__()
@@ -1144,6 +1208,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
entrances: typing.ClassVar[typing.AbstractSet[str]] entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]] exits: typing.ClassVar[typing.AbstractSet[str]]
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
duplicate_exits: bool = False duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate.""" """Whether or not exits should be allowed to be duplicate."""
@@ -1435,6 +1501,7 @@ class DeathLink(Toggle):
class ItemLinks(OptionList): class ItemLinks(OptionList):
"""Share part of your item pool with other players.""" """Share part of your item pool with other players."""
display_name = "Item Links" display_name = "Item Links"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
rich_text_doc = True rich_text_doc = True
default = [] default = []
schema = Schema([ schema = Schema([
@@ -1519,6 +1586,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
default = () default = ()
supports_weighting = False supports_weighting = False
display_name = "Plando Items" display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None: def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value)) self.value = list(deepcopy(value))
@@ -1629,7 +1697,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
def __len__(self) -> int: def __len__(self) -> int:
return len(self.value) return len(self.value)
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True rich_text_doc = True
@@ -1715,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from Utils import local_path, __version__ from Utils import local_path, __version__
full_path: str full_path: str
preset_folder = os.path.join(target_folder, "Presets")
os.makedirs(target_folder, exist_ok=True) os.makedirs(target_folder, exist_ok=True)
os.makedirs(preset_folder, exist_ok=True)
# clean out old # clean out old
for file in os.listdir(target_folder): for file in os.listdir(target_folder):
@@ -1724,18 +1794,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"): if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path) os.unlink(full_path)
def dictify_range(option: Range): for file in os.listdir(preset_folder):
data = {option.default: 50} full_path = os.path.join(preset_folder, file)
for sub_option in ["random", "random-low", "random-high"]: if os.path.isfile(full_path) and full_path.endswith(".yaml"):
if sub_option != option.default: os.unlink(full_path)
data[sub_option] = 0
notes = {} def dictify_range(option: Range, option_val: int | str):
data = {option_val: 50}
for sub_option in ["random", "random-low", "random-high",
f"random-range-{option.range_start}-{option.range_end}"]:
if sub_option != option_val:
data[sub_option] = 0
notes = {
"random-low": "random value weighted towards lower values",
"random-high": "random value weighted towards higher values",
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
f"{option.range_start} and {option.range_end}"
}
for name, number in getattr(option, "special_range_names", {}).items(): for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}" notes[name] = f"equivalent to {number}"
if number in data: if number in data:
data[name] = data[number] data[name] = data[number]
del data[number] del data[number]
elif name in data:
pass
else: else:
data[name] = 0 data[name] = 0
@@ -1751,20 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
presets = world.web.options_presets.copy()
presets.update({"": {}})
option_groups = get_option_groups(world) option_groups = get_option_groups(world)
for name, preset in presets.items():
res = template.render( res = template.render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, __version__=__version__,
game=game_name, game=game_name,
world_version=world.world_version.as_simple_string(), world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc, cleandoc=cleandoc,
) preset_name=name,
preset=preset,
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: )
f.write(res) 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)
def dump_player_options(multiworld: MultiWorld) -> None: def dump_player_options(multiworld: MultiWorld) -> None:

708
OptionsCreator.py Normal file
View File

@@ -0,0 +1,708 @@
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
ToggleButton, MarkupDropdown, ResizableTextField)
from kivy.clock import Clock
from kivy.uix.behaviors.button import ButtonBehavior
from kivymd.uix.behaviors import RotateBehavior
from kivymd.uix.anchorlayout import MDAnchorLayout
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
from kivymd.uix.slider import MDSlider
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
from kivymd.uix.dialog import MDDialog
from kivy.core.text.markup import MarkupLabel
from kivy.utils import escape_markup
from kivy.lang.builder import Builder
from kivy.properties import ObjectProperty
from textwrap import dedent
from copy import deepcopy
import Utils
import typing
import webbrowser
import re
from urllib.parse import urlparse
from worlds.AutoWorld import AutoWorldRegister, World
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList,
OptionCounter, Visibility)
def validate_url(x):
try:
result = urlparse(x)
return all([result.scheme, result.netloc])
except AttributeError:
return False
def filter_tooltip(tooltip):
if tooltip is None:
tooltip = "No tooltip available."
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&amp;") \
.replace("[", "&bl;").replace("]", "&br;")
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
return escape_markup(tooltip)
def option_can_be_randomized(option: typing.Type[Option]):
# most options can be randomized, so we should just check for those that cannot
if not option.supports_weighting:
return False
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
return False
return True
def check_random(value: typing.Any):
if not isinstance(value, str):
return value # cannot be random if evaluated
if value.startswith("random-"):
return "random"
return value
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
pass
class WorldButton(ToggleButton):
world_cls: typing.Type[World]
class VisualRange(MDBoxLayout):
option: typing.Type[Range]
name: str
tag: MDLabel = ObjectProperty(None)
slider: MDSlider = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
def update_points(*update_args):
pass
self.slider._update_points = update_points
class VisualChoice(MDButton):
option: typing.Type[Choice]
name: str
text: MDButtonText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualNamedRange(MDBoxLayout):
option: typing.Type[NamedRange]
name: str
range: VisualRange = ObjectProperty(None)
choice: MDButton = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
self.range = range_widget
self.add_widget(self.range)
class VisualFreeText(ResizableTextField):
option: typing.Type[FreeText] | typing.Type[TextChoice]
name: str
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class VisualTextChoice(MDBoxLayout):
option: typing.Type[TextChoice]
name: str
choice: VisualChoice = ObjectProperty(None)
text: VisualFreeText = ObjectProperty(None)
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
text: VisualFreeText, **kwargs):
self.option = option
self.name = name
super(MDBoxLayout, self).__init__(*args, **kwargs)
self.choice = choice
self.text = text
self.add_widget(self.choice)
self.add_widget(self.text)
class VisualToggle(MDBoxLayout):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[Toggle]
name: str
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
self.option = option
self.name = name
super().__init__(*args, **kwargs)
class CounterItemValue(ResizableTextField):
pat = re.compile('[^0-9]')
def insert_text(self, substring, from_undo=False):
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
class VisualListSetCounter(MDDialog):
button: MDIconButton = ObjectProperty(None)
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
scrollbox: ScrollBox = ObjectProperty(None)
add: MDIconButton = ObjectProperty(None)
save: MDButton = ObjectProperty(None)
input: ResizableTextField = ObjectProperty(None)
dropdown: MDDropdownMenu
valid_keys: typing.Iterable[str]
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
name: str, valid_keys: typing.Iterable[str], **kwargs):
self.option = option
self.name = name
self.valid_keys = valid_keys
super().__init__(*args, **kwargs)
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
width=self.input.width, position="bottom")
self.input.bind(text=self.on_text)
self.input.bind(on_text_validate=self.validate_add)
def validate_add(self, instance):
if self.valid_keys:
if self.input.text not in self.valid_keys:
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
if not issubclass(self.option, OptionList):
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
return
self.add_set_item(self.input.text)
self.input.set_text(self.input, "")
def remove_item(self, button: MDIconButton):
list_item = button.parent
self.scrollbox.layout.remove_widget(list_item)
def add_set_item(self, key: str, value: int | None = None):
text = MDListItemSupportingText(text=key, id="value")
if issubclass(self.option, OptionCounter):
value_txt = CounterItemValue(text=str(value) if value else "1")
item = MDListItem(text,
value_txt,
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.value = value_txt
else:
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
item.text = text
self.scrollbox.layout.add_widget(item)
def on_text(self, instance, value):
if not self.valid_keys:
return
if len(value) >= 3:
self.dropdown.items.clear()
def on_press(txt):
split_text = MarkupLabel(text=txt, markup=True).markup
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.input.focus = True
self.dropdown.dismiss()
lowered = value.lower()
for item_name in self.valid_keys:
try:
index = item_name.lower().index(lowered)
except ValueError:
pass # substring not found
else:
text = escape_markup(item_name)
text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
self.dropdown.items.append({
"text": text,
"on_release": lambda txt=text: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
self.dropdown.open()
else:
self.dropdown.dismiss()
class OptionsCreator(ThemedApp):
base_title: str = "Archipelago Options Creator"
container: ContainerLayout
main_layout: MainLayout
scrollbox: ScrollBox
main_panel: MainLayout
player_options: MainLayout
option_layout: MainLayout
name_input: ResizableTextField
game_label: MDLabel
current_game: str
options: typing.Dict[str, typing.Any]
def __init__(self):
self.title = self.base_title + " " + Utils.__version__
self.icon = r"data/icon.png"
self.current_game = ""
self.options = {}
super().__init__()
@staticmethod
def show_result_snack(text: str) -> None:
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
def on_export_result(self, text: str | None) -> None:
self.container.disabled = False
if text is not None:
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
def export_options_background(self, options: dict[str, typing.Any]) -> None:
try:
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
except Exception:
self.on_export_result("Could not open dialog. Already open?")
raise
if not file_name:
self.on_export_result(None) # No file selected. No need to show a message for this.
return
try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
self.on_export_result("File saved successfully.")
except Exception:
self.on_export_result("Could not save file.")
raise
def export_options(self, button: Widget) -> None:
if 0 < len(self.name_input.text) < 17 and self.current_game:
import threading
options = {
"name": self.name_input.text,
"description": f"YAML generated by Archipelago {Utils.__version__}.",
"game": self.current_game,
self.current_game: {k: check_random(v) for k, v in self.options.items()}
}
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
self.container.disabled = True
elif not self.name_input.text:
self.show_result_snack("Name must not be empty.")
elif not self.current_game:
self.show_result_snack("You must select a game to play.")
else:
self.show_result_snack("Name cannot be longer than 16 characters.")
def create_range(self, option: typing.Type[Range], name: str, bind=True):
def update_text(range_box: VisualRange):
self.options[name] = int(range_box.slider.value)
range_box.tag.text = str(int(range_box.slider.value))
return
box = VisualRange(option=option, name=name)
if bind:
box.slider.bind(value=lambda _, _1: update_text(box))
self.options[name] = option.default
return box
def create_named_range(self, option: typing.Type[NamedRange], name: str):
def set_to_custom(range_box: VisualNamedRange):
range_box.range.tag.text = str(int(range_box.range.slider.value))
if range_box.range.slider.value in option.special_range_names.values():
value = next(key for key, val in option.special_range_names.items()
if val == range_box.range.slider.value)
self.options[name] = value
set_button_text(box.choice, value.title())
else:
self.options[name] = int(range_box.range.slider.value)
set_button_text(range_box.choice, "Custom")
def set_button_text(button: MDButton, text: str):
button.text.text = text
def set_value(text: str, range_box: VisualNamedRange):
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
option.range_end)
range_box.range.tag.text = str(option.special_range_names[text.lower()])
set_button_text(range_box.choice, text)
self.options[name] = text.lower()
range_box.range.slider.dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
box.range.slider.dropdown.open()
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name, bind=False))
default: int | str = option.default
if default in option.special_range_names:
# value can get mismatched in this case
box.range.slider.value = min(max(option.special_range_names[default], option.range_start),
option.range_end)
box.range.tag.text = str(int(box.range.slider.value))
elif default in option.special_range_names.values():
# better visual
default = next(key for key, val in option.special_range_names.items() if val == option.default)
set_button_text(box.choice, default.title())
box.range.slider.bind(value=lambda _, _2: set_to_custom(box))
items = [
{
"text": choice.title(),
"on_release": lambda text=choice.title(): set_value(text, box)
}
for choice in option.special_range_names
]
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
box.choice.bind(on_release=open_dropdown)
self.options[name] = default
return box
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
text.bind(on_text_validate=set_value)
return text
def create_choice(self, option: typing.Type[Choice], name: str):
def set_button_text(button: VisualChoice, text: str):
button.text.text = text
def set_value(text, value):
set_button_text(main_button, text)
self.options[name] = value
dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
dropdown.open()
default_string = isinstance(option.default, str)
main_button = VisualChoice(option=option, name=name)
main_button.bind(on_release=open_dropdown)
items = [
{
"text": option.get_option_name(choice),
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
}
for choice in option.name_lookup
]
dropdown = MDDropdownMenu(caller=main_button, items=items)
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
return main_button
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
def set_button_text(button: MDButton, text: str):
for child in button.children:
if isinstance(child, MDButtonText):
child.text = text
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
text=self.create_free_text(option, name))
def set_value(instance):
set_button_text(box.choice, "Custom")
self.options[name] = instance.text
box.text.bind(on_text_validate=set_value)
return box
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
def set_value(instance: MDIconButton):
if instance.icon == "checkbox-outline":
instance.icon = "checkbox-blank-outline"
else:
instance.icon = "checkbox-outline"
self.options[name] = bool(not self.options[name])
self.options[name] = bool(option.default)
checkbox = VisualToggle(option=option, name=name)
checkbox.button.bind(on_release=set_value)
return checkbox
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
name: str, world: typing.Type[World]):
valid_keys = sorted(option.valid_keys)
if option.verify_item_name:
valid_keys += list(world.item_name_to_id.keys())
if option.convert_name_groups:
valid_keys += list(world.item_name_groups.keys())
if option.verify_location_name:
valid_keys += list(world.location_name_to_id.keys())
if option.convert_name_groups:
valid_keys += list(world.location_name_groups.keys())
if not issubclass(option, OptionCounter):
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name].append(getattr(list_item.text, "text"))
dialog.dismiss()
else:
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
dialog.dismiss()
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
dialog.ids.container.spacing = dp(30)
dialog.scrollbox.layout.theme_bg_color = "Custom"
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
dialog.scrollbox.layout.spacing = dp(5)
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
if issubclass(option, OptionCounter):
for value in sorted(self.options[name]):
dialog.add_set_item(value, self.options[name].get(value, None))
else:
for value in sorted(self.options[name]):
dialog.add_set_item(value)
dialog.save.bind(on_release=apply_changes)
dialog.open()
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
if name not in self.options:
# convert from non-mutable to mutable
# We use list syntax even for sets, set behavior is enforced through GUI
if issubclass(option, OptionCounter):
self.options[name] = deepcopy(option.default)
else:
self.options[name] = sorted(option.default)
return main_button
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
tooltip = filter_tooltip(option.__doc__)
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
label_box = MDBoxLayout(orientation="horizontal")
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
label_anchor.add_widget(option_label)
label_box.add_widget(label_anchor)
option_base.add_widget(label_box)
if issubclass(option, NamedRange):
option_base.add_widget(self.create_named_range(option, name))
elif issubclass(option, Range):
option_base.add_widget(self.create_range(option, name))
elif issubclass(option, Toggle):
option_base.add_widget(self.create_toggle(option, name))
elif issubclass(option, TextChoice):
option_base.add_widget(self.create_text_choice(option, name))
elif issubclass(option, Choice):
option_base.add_widget(self.create_choice(option, name))
elif issubclass(option, FreeText):
option_base.add_widget(self.create_free_text(option, name))
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
else:
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
"Please edit your yaml manually to set this option."))
if option_can_be_randomized(option):
def randomize_option(instance: Widget, value: str):
value = value == "down"
if value:
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric():
self.options[name] = int(self.options[name])
elif self.options[name] in ("True", "False"):
self.options[name] = self.options[name] == "True"
base_object = instance.parent.parent
label_object = instance.parent
for child in base_object.children:
if child is not label_object:
child.disabled = value
default_random = option.default == "random"
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
state="down" if default_random else "normal")
random_toggle.bind(state=randomize_option)
label_box.add_widget(random_toggle)
if default_random:
randomize_option(random_toggle, "down")
return option_base
def create_options_panel(self, world_button: WorldButton):
self.option_layout.clear_widgets()
self.options.clear()
cls: typing.Type[World] = world_button.world_cls
self.current_game = cls.game
if not cls.web.options_page:
self.current_game = "None"
return
elif isinstance(cls.web.options_page, str):
self.current_game = "None"
if validate_url(cls.web.options_page):
webbrowser.open(cls.web.options_page)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
else:
# attach onto archipelago.gg and see if we pass
new_url = "https://archipelago.gg/" + cls.web.options_page
if validate_url(new_url):
webbrowser.open(new_url)
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
else:
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
world_button.state = "normal"
# else just fall through
else:
expansion_box = ScrollBox()
expansion_box.layout.orientation = "vertical"
expansion_box.layout.spacing = dp(3)
expansion_box.scroll_type = ["bars"]
expansion_box.do_scroll_x = False
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
groups = {name: [] for name in group_names}
for name, option in cls.options_dataclass.type_hints.items():
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
groups[group].append((name, option))
for group, options in groups.items():
options = [(name, option) for name, option in options
if name and option.visibility & Visibility.simple_ui]
if not options:
continue # Game Options can be empty if every other option is in another group
# Can also have an option group of options that should not render on simple ui
group_item = MDExpansionPanel(size_hint_y=None)
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
TrailingPressedIconButton(icon="chevron-right",
on_release=lambda x,
item=group_item:
self.tap_expansion_chevron(
item, x)),
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
theme_bg_color="Custom",
on_release=lambda x, item=group_item:
self.tap_expansion_chevron(item, x)))
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
padding=[dp(12), dp(100), dp(12), 0],
spacing=dp(3))
group_item.add_widget(group_header)
group_item.add_widget(group_content)
group_box = ScrollBox()
group_box.layout.orientation = "vertical"
group_box.layout.spacing = dp(3)
for name, option in options:
group_content.add_widget(self.create_option(option, name, cls))
expansion_box.layout.add_widget(group_item)
self.option_layout.add_widget(expansion_box)
self.game_label.text = f"Game: {self.current_game}"
@staticmethod
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
if isinstance(chevron, MDListItem):
chevron = next((child for child in chevron.ids.trailing_container.children
if isinstance(child, TrailingPressedIconButton)), None)
panel.open() if not panel.is_open else panel.close()
if chevron:
panel.set_chevron_down(
chevron
) if not panel.is_open else panel.set_chevron_up(chevron)
def build(self):
self.set_colors()
self.options = {}
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
self.root = self.container
self.main_layout = self.container.ids.main
self.scrollbox = self.container.ids.scrollbox
def world_button_action(world_btn: WorldButton):
if self.current_game != world_btn.world_cls.game:
old_button = next((button for button in self.scrollbox.layout.children
if button.world_cls.game == self.current_game), None)
if old_button:
old_button.state = "normal"
else:
world_btn.state = "down"
self.create_options_panel(world_btn)
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
if cls.hidden:
continue
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
pos_hint={"x": 0.03, "center_y": 0.5})
world_text.text_size = (world_text.width, None)
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
texture_size=lambda *x, text=world_text: text.setter("height")(text,
world_text.texture_size[1]))
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
radius=(dp(5), dp(5), dp(5), dp(5)))
world_button.bind(on_release=world_button_action)
world_button.world_cls = cls
self.scrollbox.layout.add_widget(world_button)
self.main_panel = self.container.ids.player_layout
self.player_options = self.container.ids.player_options
self.game_label = self.container.ids.game
self.name_input = self.container.ids.player_name
self.option_layout = self.container.ids.options
def set_height(instance, value):
instance.height = value[1]
self.game_label.bind(texture_size=set_height)
# 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
# from kivy.core.window import Window
# create_console(Window, self.container)
return self.container
def launch():
OptionsCreator().run()
if __name__ == "__main__":
Utils.init_logging("OptionsCreator")
launch()

View File

@@ -82,6 +82,10 @@ Currently, the following games are supported:
* Paint * Paint
* Celeste (Open World) * Celeste (Open World)
* Choo-Choo Charles * Choo-Choo Charles
* APQuest
* Satisfactory
* EarthBound
* Mega Man 3
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
import time
import asyncio import asyncio
import typing import typing
import bsdiff4 import bsdiff4
@@ -15,6 +16,9 @@ from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, logger, get_base_parser gui_enabled, ClientCommandProcessor, logger, get_base_parser
from Utils import async_start from Utils import async_start
# Heartbeat for position sharing via bounces, in seconds
UNDERTALE_STATUS_INTERVAL = 30.0
UNDERTALE_ONLINE_TIMEOUT = 60.0
class UndertaleCommandProcessor(ClientCommandProcessor): class UndertaleCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx): def __init__(self, ctx):
@@ -109,6 +113,11 @@ class UndertaleContext(CommonContext):
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
# self.save_game_folder: files go in this path to pass data between us and the actual game # self.save_game_folder: files go in this path to pass data between us and the actual game
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
self.last_sent_position: typing.Optional[tuple] = None
self.last_room: typing.Optional[str] = None
self.last_status_write: float = 0.0
self.other_undertale_status: dict[int, dict] = {}
def patch_game(self): def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f: with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
@@ -219,6 +228,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist", str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}]) str(ctx.slot)+" RoutesDone genocide"]}])
if any(info.game == "Undertale" and slot != ctx.slot
for slot, info in ctx.slot_info.items()):
ctx.set_notify("undertale_room_status")
if args["slot_data"]["only_flakes"]: if args["slot_data"]["only_flakes"]:
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
f.close() f.close()
@@ -263,6 +275,12 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]:
status = args["keys"]["undertale_room_status"]
ctx.other_undertale_status = {
int(key): val for key, val in status.items()
if int(key) != ctx.slot
}
elif cmd == "SetReply": elif cmd == "SetReply":
if args["value"] is not None: if args["value"] is not None:
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
@@ -271,17 +289,19 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
ctx.completed_routes["genocide"] = args["value"] ctx.completed_routes["genocide"] = args["value"]
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
ctx.completed_routes["neutral"] = args["value"] ctx.completed_routes["neutral"] = args["value"]
if args.get("key") == "undertale_room_status" and args.get("value"):
ctx.other_undertale_status = {
int(key): val for key, val in args["value"].items()
if int(key) != ctx.slot
}
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
start_index = args["index"] start_index = args["index"]
if start_index == 0: if start_index == 0:
ctx.items_received = [] ctx.items_received = []
elif start_index != len(ctx.items_received): elif start_index != len(ctx.items_received):
sync_msg = [{"cmd": "Sync"}] await ctx.check_locations(ctx.locations_checked)
if ctx.locations_checked: await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received): if start_index == len(ctx.items_received):
counter = -1 counter = -1
placedWeapon = 0 placedWeapon = 0
@@ -368,9 +388,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
f.close() f.close()
elif cmd == "Bounced": elif cmd == "Bounced":
tags = args.get("tags", []) data = args.get("data", {})
if "Online" in tags: if "x" in data and "room" in data:
data = args.get("data", {})
if data["player"] != ctx.slot and data["player"] is not None: if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot" filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
@@ -381,21 +400,63 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
async def multi_watcher(ctx: UndertaleContext): async def multi_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
path = ctx.save_game_folder if "Online" in ctx.tags and any(
for root, dirs, files in os.walk(path): info.game == "Undertale" and slot != ctx.slot
for file in files: for slot, info in ctx.slot_info.items()):
if "spots.mine" in file and "Online" in ctx.tags: now = time.time()
with open(os.path.join(root, file), "r") as mine: path = ctx.save_game_folder
this_x = mine.readline() for root, dirs, files in os.walk(path):
this_y = mine.readline() for file in files:
this_room = mine.readline() if "spots.mine" in file:
this_sprite = mine.readline() with open(os.path.join(root, file), "r") as mine:
this_frame = mine.readline() this_x = mine.readline()
mine.close() this_y = mine.readline()
message = [{"cmd": "Bounce", "tags": ["Online"], this_room = mine.readline()
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, this_sprite = mine.readline()
"spr": this_sprite, "frm": this_frame}}] this_frame = mine.readline()
await ctx.send_msgs(message)
if this_room != ctx.last_room or \
now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL:
ctx.last_room = this_room
ctx.last_status_write = now
await ctx.send_msgs([{
"cmd": "Set",
"key": "undertale_room_status",
"default": {},
"want_reply": False,
"operations": [{"operation": "update",
"value": {str(ctx.slot): {"room": this_room,
"time": now}}}]
}])
# If player was visible but timed out (heartbeat) or left the room, remove them.
for slot, entry in ctx.other_undertale_status.items():
if entry.get("room") != this_room or \
now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT:
playerspot = os.path.join(ctx.save_game_folder,
f"FRISK{slot}.playerspot")
if os.path.exists(playerspot):
os.remove(playerspot)
current_position = (this_x, this_y, this_room, this_sprite, this_frame)
if current_position == ctx.last_sent_position:
continue
# Empty status dict = no data yet → send to bootstrap.
online_in_room = any(
entry.get("room") == this_room and
now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT
for entry in ctx.other_undertale_status.values()
)
if ctx.other_undertale_status and not online_in_room:
continue
message = [{"cmd": "Bounce", "games": ["Undertale"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y,
"room": this_room, "spr": this_sprite,
"frm": this_frame}}]
await ctx.send_msgs(message)
ctx.last_sent_position = current_position
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -409,10 +470,9 @@ async def game_watcher(ctx: UndertaleContext):
for file in files: for file in files:
if ".item" in file: if ".item" in file:
os.remove(os.path.join(root, file)) os.remove(os.path.join(root, file))
sync_msg = [{"cmd": "Sync"}] await ctx.check_locations(ctx.locations_checked)
if ctx.locations_checked: await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False ctx.syncing = False
if ctx.got_deathlink: if ctx.got_deathlink:
ctx.got_deathlink = False ctx.got_deathlink = False
@@ -447,7 +507,7 @@ async def game_watcher(ctx: UndertaleContext):
for l in lines: for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000] sending = sending+[(int(l.rstrip('\n')))+12000]
finally: finally:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) await ctx.check_locations(sending)
if "victory" in file and str(ctx.route) in file: if "victory" in file and str(ctx.route) in file:
victory = True victory = True
if ".playerspot" in file and "Online" not in ctx.tags: if ".playerspot" in file and "Online" not in ctx.tags:

231
Utils.py
View File

@@ -18,10 +18,14 @@ import logging
import warnings import warnings
from argparse import Namespace from argparse import Namespace
from datetime import datetime, timezone
from settings import Settings, get_settings from settings import Settings, get_settings
from time import sleep from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
try: try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
@@ -48,7 +52,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self) return ".".join(str(item) for item in self)
__version__ = "0.6.4" __version__ = "0.6.7"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
@@ -314,12 +318,9 @@ def get_public_ipv6() -> str:
return ip return ip
OptionsType = Settings # TODO: remove when removing get_options @deprecated("Utils.get_options() is deprecated. Use the settings API instead.")
def get_options() -> Settings: def get_options() -> Settings:
# TODO: switch to Utils.deprecate after 0.4.4 deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
return get_settings() return get_settings()
@@ -391,6 +392,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
logging.debug(f"Could not store data package: {e}") logging.debug(f"Could not store data package: {e}")
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
try:
with open(filename) as ignore_file:
return GitIgnoreSpec.from_lines(ignore_file)
except FileNotFoundError:
return None
def get_default_adjuster_settings(game_name: str) -> Namespace: def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster import LttPAdjuster
adjuster_settings = Namespace() adjuster_settings = Namespace()
@@ -755,6 +764,11 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args)) res.put(open_filename(*args))
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(save_filename(*args))
def _run_for_stdout(*args: str): def _run_for_stdout(*args: str):
env = os.environ env = os.environ
if "LD_LIBRARY_PATH" in env: if "LD_LIBRARY_PATH" in env:
@@ -801,8 +815,62 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError: except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel" return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw() root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), try:
initialfile=suggest or None) return tkinter.filedialog.askopenfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because save_filename was used for "{title}".')
raise e
else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
try:
return tkinter.filedialog.asksaveasfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
@@ -850,6 +918,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
if not gui_enabled:
if error:
logging.error(f"{title}: {text}")
else:
logging.info(f"{title}: {text}")
return
if is_kivy_running(): if is_kivy_running():
from kvui import MessageBox from kvui import MessageBox
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
@@ -885,6 +960,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update() root.update()
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str: def sorter(element: Union[str, Dict[str, Any]]) -> str:
@@ -929,6 +1007,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
def deprecate(message: str, add_stacklevels: int = 0): def deprecate(message: str, add_stacklevels: int = 0):
"""also use typing_extensions.deprecated wherever you use this"""
if __debug__: if __debug__:
raise Exception(message) raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels) warnings.warn(message, stacklevel=2 + add_stacklevels)
@@ -993,6 +1072,7 @@ def _extend_freeze_support() -> None:
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
@deprecated("Use multiprocessing.freeze_support() instead")
def freeze_support() -> None: def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
import multiprocessing import multiprocessing
@@ -1004,9 +1084,18 @@ def freeze_support() -> None:
_extend_freeze_support() _extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *, def visualize_regions(
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, root_region: Region,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: file_name: str,
*,
show_entrance_names: bool = False,
show_locations: bool = True,
show_other_regions: bool = True,
linetype_ortho: bool = True,
regions_to_highlight: set[Region] | None = None,
entrance_highlighting: dict[int, int] | None = None,
detail_other_regions: bool = False,
auto_assign_colors: bool = False) -> None:
"""Visualize the layout of a world as a PlantUML diagram. """Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -1023,6 +1112,13 @@ def visualize_regions(root_region: Region, file_name: str, *,
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable. :param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
your entrances
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
from root_region.
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
not found in the passed-in map
Example usage in World code: Example usage in World code:
from Utils import visualize_regions from Utils import visualize_regions
@@ -1048,6 +1144,34 @@ def visualize_regions(root_region: Region, file_name: str, *,
regions: typing.Deque[Region] = deque((root_region,)) regions: typing.Deque[Region] = deque((root_region,))
multiworld: MultiWorld = root_region.multiworld multiworld: MultiWorld = root_region.multiworld
colors_used: set[int] = set()
if entrance_highlighting:
for color in entrance_highlighting.values():
# filter the colors to their most-significant bits to avoid too similar colors
colors_used.add(color & 0xF0F0F0)
else:
# assign an empty dict to not crash later
# the parameter is optional for ease of use when you don't care about colors
entrance_highlighting = {}
def select_color(group: int) -> int:
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
# obvious cyclical color patterns
COLOR_INDEX_SPACING: int = 0x357
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
while new_color in colors_used:
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
# and worlds are unlikely to get to anywhere close to that many entrance groups
# intentionally not using multiworld.random to not affect output when debugging with this tool
new_color_index += COLOR_INDEX_SPACING
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
return new_color
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
name = obj.name name = obj.name
if isinstance(obj, Item): if isinstance(obj, Item):
@@ -1067,18 +1191,28 @@ def visualize_regions(root_region: Region, file_name: str, *,
def visualize_exits(region: Region) -> None: def visualize_exits(region: Region) -> None:
for exit_ in region.exits: 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: if exit_.connected_region:
if show_entrance_names: if show_entrance_names:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
else: else:
try: try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
except ValueError: except ValueError:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
else: else:
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
for entrance in region.entrances:
color_code: str = ""
if entrance.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
if not entrance.parent_region:
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
def visualize_locations(region: Region) -> None: def visualize_locations(region: Region) -> None:
any_lock = any(location.locked for location in region.locations) any_lock = any(location.locked for location in region.locations)
@@ -1099,9 +1233,27 @@ def visualize_regions(root_region: Region, file_name: str, *,
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
uml.append("package \"other regions\" <<Cloud>> {") uml.append("package \"other regions\" <<Cloud>> {")
for region in other_regions: for region in other_regions:
uml.append(f"class \"{fmt(region)}\"") if detail_other_regions:
visualize_region(region)
else:
uml.append(f"class \"{fmt(region)}\"")
uml.append("}") uml.append("}")
if auto_assign_colors:
all_entrances: list[Entrance] = []
for region in multiworld.get_regions(root_region.player):
all_entrances.extend(region.entrances)
all_entrances.extend(region.exits)
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
for group in all_groups:
if group not in entrance_highlighting:
if len(colors_used) >= 0x1000:
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
break
new_color: int = select_color(group)
entrance_highlighting[group] = new_color
colors_used.add(new_color)
uml.append("@startuml") uml.append("@startuml")
uml.append("hide circle") uml.append("hide circle")
uml.append("hide empty members") uml.append("hide empty members")
@@ -1112,7 +1264,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
seen.add(current_region) seen.add(current_region)
visualize_region(current_region) visualize_region(current_region)
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
if show_other_regions: if show_other_regions or detail_other_regions:
visualize_other_regions() visualize_other_regions()
uml.append("@enduml") uml.append("@enduml")
@@ -1141,6 +1293,15 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
return isinstance(obj, typing.Iterable) 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): class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
""" """
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
@@ -1176,3 +1337,35 @@ class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
t.start() t.start()
self._threads.add(t) self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown # NOTE: don't add to _threads_queues so we don't block on shutdown
def get_full_typename(t: type) -> str:
"""Returns the full qualified name of a type, including its module (if not builtins)."""
module = t.__module__
if module and module != "builtins":
return f"{module}.{t.__qualname__}"
return t.__qualname__
def get_all_causes(ex: Exception) -> str:
"""Return a string describing the recursive causes of this exception.
:param ex: The exception to be described.
:return A multiline string starting with the initial exception on the first line and each resulting exception
on subsequent lines with progressive indentation.
For example:
```
Exception: Invalid value 'bad'.
Which caused: Options.OptionError: Error generating option
Which caused: ValueError: File bad.yaml is invalid.
```
"""
cause = ex
causes = [f"{get_full_typename(type(ex))}: {ex}"]
while cause := cause.__cause__:
causes.append(f"{get_full_typename(type(cause))}: {cause}")
top = causes[-1]
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
return f"{top}{others}"

View File

@@ -20,7 +20,8 @@ if typing.TYPE_CHECKING:
Utils.local_path.cached_path = os.path.dirname(__file__) Utils.local_path.cached_path = os.path.dirname(__file__)
settings.no_gui = True settings.no_gui = True
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath):
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))

View File

@@ -1,46 +1,20 @@
# WebHost # WebHost
## Asset License
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
level LICENSE.
See individual LICENSE files in `./static/static/**`.
You are only allowed to use them for personal use, testing and development.
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
and do not promote it publicly. Alternatively replace or remove the assets.
## Contribution Guidelines ## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
### Small Changes Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
Little changes like adding a button or a couple new select elements are perfectly fine. Design changes have to fit the overall design.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
### Content Additions Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
### Restrictions on Style Changes See also [docs/style.md](/docs/style.md) for the style guide.
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -11,6 +11,7 @@ from pony.flask import Pony
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from Utils import title_sorted, get_file_safe_name from Utils import title_sorted, get_file_safe_name
from .cli import CLI
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -23,6 +24,17 @@ app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
# overwrites of flask default config
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["SESSION_PERMANENT"] = True
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
# custom config
app.config["SELFHOST"] = True # application process is in charge of running the websites app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters app.config["HOSTERS"] = 8 # maximum concurrent room hosters
@@ -30,19 +42,14 @@ 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["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["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["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread # at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1 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. # 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 app.config["JOB_TIME"] = 600
# maximum time in seconds since last activity for a room to be hosted
app.config["MAX_ROOM_TIMEOUT"] = 259200
# memory limit for generator processes in bytes # memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296 app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
# archipelago.gg uses gunicorn + nginx; ignoring this option # archipelago.gg uses gunicorn + nginx; ignoring this option
@@ -60,6 +67,7 @@ app.config["ASSET_RIGHTS"] = False
cache = Cache() cache = Cache()
Compress(app) Compress(app)
CLI(app)
def to_python(value: str) -> uuid.UUID: def to_python(value: str) -> uuid.UUID:

View File

@@ -2,10 +2,20 @@
from typing import List, Tuple from typing import List, Tuple
from flask import Blueprint from flask import Blueprint
from flask_cors import CORS
from ..models import Seed, Slot from ..models import Seed, Slot
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
cors = CORS(api_endpoints, resources={
r"/api/datapackage/*": {"origins": "*"},
r"/api/datapackage": {"origins": "*"},
r"/api/datapackage_checksum/*": {"origins": "*"},
r"/api/room_status/*": {"origins": "*"},
r"/api/tracker/*": {"origins": "*"},
r"/api/static_tracker/*": {"origins": "*"},
r"/api/slot_data_tracker/*": {"origins": "*"}
})
def get_players(seed: Seed) -> List[Tuple[str, str]]: def get_players(seed: Seed) -> List[Tuple[str, str]]:

View File

@@ -58,6 +58,12 @@ class PlayerLocationsTotal(TypedDict):
total_locations: int total_locations: int
class PlayerGame(TypedDict):
team: int
player: int
game: str
@api_endpoints.route("/tracker/<suuid:tracker>") @api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60) @cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]: def tracker_data(tracker: UUID) -> dict[str, Any]:
@@ -80,7 +86,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"""Slot aliases of all players.""" """Slot aliases of all players."""
for team, players in all_players.items(): for team, players in all_players.items():
for player in players: for player in players:
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)}) player_aliases.append(
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = [] player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player.""" """Items received by each player."""
@@ -94,7 +101,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
for team, players in all_players.items(): for team, players in all_players.items():
for player in players: for player in players:
player_checks_done.append( player_checks_done.append(
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) {"team": team, "player": player,
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [ total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done} {"team": team, "checks_done": checks_done}
@@ -144,7 +152,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
"""The current client status for each player.""" """The current client status for each player."""
for team, players in all_players.items(): for team, players in all_players.items():
for player in players: for player in players:
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)}) player_status.append(
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return { return {
"aliases": player_aliases, "aliases": player_aliases,
@@ -207,12 +216,20 @@ def static_tracker_data(tracker: UUID) -> dict[str, Any]:
player_locations_total.append( player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))}) {"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
player_game: list[PlayerGame] = []
"""The played game per player slot."""
for team, players in all_players.items():
for player in players:
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
return { return {
"groups": groups, "groups": groups,
"datapackage": tracker_data._multidata["datapackage"], "datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total, "player_locations_total": player_locations_total,
"player_game": player_game,
} }
# It should be exceedingly rare that slot data is needed, so it's separated out. # It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>") @api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300) @cache.memoize(timeout=300)

View File

@@ -4,14 +4,14 @@ import json
import logging import logging
import multiprocessing import multiprocessing
import typing import typing
from datetime import timedelta, datetime from datetime import timedelta
from threading import Event, Thread from threading import Event, Thread
from typing import Any from typing import Any
from uuid import UUID 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 from .locker import Locker, AlreadyRunningException
_stop_event = Event() _stop_event = Event()
@@ -129,10 +129,11 @@ def autohost(config: dict):
with db_session: with db_session:
rooms = select( rooms = select(
room for room in Room if 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: for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. # 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) hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException: except AlreadyRunningException:

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
WebHostLib/cli/stats.py Normal file
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}")

View File

@@ -89,19 +89,24 @@ class WebHostContext(Context):
setattr(self, key, value) setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names) self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self): async def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self) cmdprocessor = DBCommandProcessor(self)
while not self.exit_event.is_set(): while not self.exit_event.is_set():
with db_session: await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
commands = select(command for command in Command if command.room.id == self.room_id) try:
if commands: await asyncio.wait_for(self.exit_event.wait(), 5)
for command in commands: except asyncio.TimeoutError:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) pass
command.delete()
commit() def _process_db_commands(self, cmdprocessor):
del commands with db_session:
time.sleep(5) commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
@db_session @db_session
def load(self, room_id: int): def load(self, room_id: int):
@@ -156,9 +161,9 @@ class WebHostContext(Context):
with db_session: with db_session:
savegame_data = Room.get(id=self.room_id).multisave savegame_data = Room.get(id=self.room_id).multisave
if savegame_data: if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) self.set_save(restricted_loads(savegame_data))
self._start_async_saving(atexit_save=False) self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start() asyncio.create_task(self.listen_to_db_commands())
@db_session @db_session
def _save(self, exit_save: bool = False) -> bool: def _save(self, exit_save: bool = False) -> bool:
@@ -167,7 +172,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.datetime.utcnow() room.last_activity = Utils.utcnow()
return True return True
def get_save(self) -> dict: def get_save(self) -> dict:
@@ -229,6 +234,17 @@ def set_up_logging(room_id) -> logging.Logger:
return logger return logger
def tear_down_logging(room_id):
"""Close logging handling for a room."""
logger_name = f"RoomLogger {room_id}"
if logger_name in logging.Logger.manager.loggerDict:
logger = logging.getLogger(logger_name)
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
del logging.Logger.manager.loggerDict[logger_name]
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], 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, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
@@ -325,7 +341,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
if ctx.saving: if ctx.saving:
ctx._save() ctx._save(True)
setattr(asyncio.current_task(), "save", None) setattr(asyncio.current_task(), "save", None)
except Exception as e: except Exception as e:
with db_session: with db_session:
@@ -336,19 +352,24 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise raise
else: else:
if ctx.saving: if ctx.saving:
ctx._save() ctx._save(True)
setattr(asyncio.current_task(), "save", None) setattr(asyncio.current_task(), "save", None)
finally: finally:
try: try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task # NOTE: async saving should probably be an async task and could be merged with shutdown_task
if ctx.server and hasattr(ctx.server, "ws_server"):
ctx.server.ws_server.close()
await ctx.server.ws_server.wait_closed()
with db_session: with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer # ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \ room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
datetime.timedelta(minutes=1, seconds=room.timeout)
del room del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.") logging.info(f"Shutting down room {room_id} on {name}.")
finally: finally:
await asyncio.sleep(5) await asyncio.sleep(5)

View File

@@ -1,8 +1,9 @@
from datetime import timedelta, datetime from datetime import timedelta
from flask import render_template from flask import render_template
from pony.orm import count from pony.orm import count
from Utils import utcnow
from WebHostLib import app, cache from WebHostLib import app, cache
from .models import Room, Seed from .models import Room, Seed
@@ -10,6 +11,6 @@ from .models import Room, Seed
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work @cache.cached(timeout=300) # cache has to appear under app route for caching to work
def landing(): def landing():
rooms = count(room for room in Room if room.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 >= datetime.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) return render_template("landing.html", rooms=rooms, seeds=seeds)

View File

@@ -1,5 +1,7 @@
import datetime import datetime
import os import os
import warnings
from enum import StrEnum
from typing import Any, IO, Dict, Iterator, List, Tuple, Union from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions import jinja2.exceptions
@@ -7,17 +9,32 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache from . import app, cache
from .markdown import render_markdown from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted from Utils import title_sorted, utcnow
class WebWorldTheme(StrEnum):
DIRT = "dirt"
GRASS = "grass"
GRASS_FLOWERS = "grassFlowers"
ICE = "ice"
JUNGLE = "jungle"
OCEAN = "ocean"
PARTY_TIME = "partyTime"
STONE = "stone"
def get_world_theme(game_name: str) -> str: def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types: if game_name not in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme return "grass"
return 'grass' chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
available_themes = [theme.value for theme in WebWorldTheme]
if chosen_theme not in available_themes:
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
return "grass"
return chosen_theme
def get_visible_worlds() -> dict[str, type(World)]: def get_visible_worlds() -> dict[str, type(World)]:
@@ -112,8 +129,13 @@ def tutorial_landing():
"authors": tutorial.authors, "authors": tutorial.authors,
"language": tutorial.language "language": tutorial.language
} }
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)} worlds = dict(
title_sorted(
worlds.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game
)
)
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials) return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@@ -212,11 +234,12 @@ def host_room(room: UUID):
if room is None: if room is None:
return abort(404) return abort(404)
now = datetime.datetime.utcnow() now = utcnow()
# indicate that the page should reload to get the assigned port # 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)) should_refresh = (
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)) (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): 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 # 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" # due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"

View File

@@ -2,6 +2,8 @@ from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
from Utils import utcnow
db = Database() db = Database()
STATE_QUEUED = 0 STATE_QUEUED = 0
@@ -20,8 +22,8 @@ class Slot(db.Entity):
class Room(db.Entity): class Room(db.Entity):
id = PrimaryKey(UUID, default=uuid4) id = PrimaryKey(UUID, default=uuid4)
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True) last_activity: datetime = Required(datetime, default=lambda: utcnow(), 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
owner = Required(UUID, index=True) owner = Required(UUID, index=True)
commands = Set('Command') commands = Set('Command')
seed = Required('Seed', index=True) seed = Required('Seed', index=True)
@@ -38,7 +40,7 @@ class Seed(db.Entity):
rooms = Set(Room) rooms = Set(Room)
multidata = Required(bytes, lazy=True) multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=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) slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True) spoiler = Optional(LongStr, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags

View File

@@ -13,6 +13,7 @@ from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from . import app, cache from . import app, cache
from .generate import get_meta from .generate import get_meta
from .misc import get_world_theme
def create() -> None: def create() -> None:
@@ -22,12 +23,6 @@ def create() -> None:
Options.generate_yaml_templates(yaml_folder) Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]: def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name] world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False: if world.hidden or world.web.options_page is False:

View File

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

View File

@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds. games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](https://archipelago.gg/games). Here is a list of our [Supported Games](/games).
## Can I generate a single-player game with Archipelago? ## Can I generate a single-player game with Archipelago?
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started? ## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience. including multiple games, and hosting multiworlds on the website for ease and convenience.
@@ -57,7 +57,7 @@ their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en). uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get? ## What happens if an item is placed somewhere it is impossible to get?

View File

@@ -241,12 +241,9 @@ input[type="checkbox"]{
} }
/* Hidden items */ /* Hidden items */
.hidden-class:not(:has(img.acquired)){ .hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
display: none; display: none;
} }
.hidden-item:not(.acquired){
display:none;
}
/* Keys */ /* Keys */
#keys ol, #keys ul{ #keys ol, #keys ul{

View File

@@ -55,6 +55,9 @@
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="named-range-container"> <div class="named-range-container">
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}> <select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.special_range_names.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for key, val in option.special_range_names.items() %} {% for key, val in option.special_range_names.items() %}
{% if option.default == val %} {% if option.default == val %}
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option> <option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
@@ -94,6 +97,9 @@
<div class="text-choice-container"> <div class="text-choice-container">
<div class="text-choice-wrapper"> <div class="text-choice-wrapper">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}> <select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.options.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for id, name in option.name_lookup.items()|sort %} {% for id, name in option.name_lookup.items()|sort %}
{% if name != "random" %} {% if name != "random" %}
{% if option.default == id %} {% if option.default == id %}

View File

@@ -33,7 +33,9 @@
<h1>Currently Supported Games</h1> <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 <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 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"> <div class="js-only">
<label for="game-search">Search for your game below!</label><br /> <label for="game-search">Search for your game below!</label><br />
<div class="page-controls"> <div class="page-controls">

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second from MultiServer import Context, get_saving_second
from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType 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 . import app, cache
from .models import GameDataPackage, Room from .models import GameDataPackage, Room
@@ -273,9 +273,10 @@ class TrackerData:
Does not include players who have no activity recorded. Does not include players who have no activity recorded.
""" """
last_activity: Dict[TeamPlayer, datetime.timedelta] = {} last_activity: Dict[TeamPlayer, datetime.timedelta] = {}
now = datetime.datetime.utcnow() now = utcnow()
for (team, player), timestamp in self._multisave.get("client_activity_timers", []): 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 return last_activity
@@ -959,7 +960,7 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids = { timespinner_location_ids = {
"Present": list(range(1337000, 1337085)), "Present": list(range(1337000, 1337085)),
"Past": list(range(1337086, 1337175)), "Past": list(range(1337086, 1337157)) + list(range(1337159, 1337175)),
"Ancient Pyramid": [ "Ancient Pyramid": [
1337236, 1337236,
1337246, 1337247, 1337248, 1337249] 1337246, 1337247, 1337248, 1337249]
@@ -1228,7 +1229,7 @@ if "Starcraft 2" in network_data_package["games"]:
def render_Starcraft2_tracker(tracker_data: TrackerData, team: int, player: int) -> str: def render_Starcraft2_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
SC2WOL_ITEM_ID_OFFSET = 1000 SC2WOL_ITEM_ID_OFFSET = 1000
SC2HOTS_ITEM_ID_OFFSET = 2000 SC2HOTS_ITEM_ID_OFFSET = 2000
SC2LOTV_ITEM_ID_OFFSET = 2000 SC2LOTV_ITEM_ID_OFFSET = 3000
SC2_KEY_ITEM_ID_OFFSET = 4000 SC2_KEY_ITEM_ID_OFFSET = 4000
NCO_LOCATION_ID_LOW = 20004500 NCO_LOCATION_ID_LOW = 20004500
NCO_LOCATION_ID_HIGH = NCO_LOCATION_ID_LOW + 1000 NCO_LOCATION_ID_HIGH = NCO_LOCATION_ID_LOW + 1000

View File

@@ -289,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
if not ctx.auth: if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '': if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate" logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
"the ROM using the same link but adding your slot name") "the ROM using the same link but adding your slot name")
if ctx.awaiting_rom: if ctx.awaiting_rom:
await ctx.server_auth(False) await ctx.server_auth(False)

2
ci-requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pytest>=9.0.1,<10 # this includes subtests support
pytest-xdist>=3.8.0

13
data/GLOBAL.apignore Normal file
View File

@@ -0,0 +1,13 @@
# This file specifies patterns that are ignored by default for any world built with the "Build APWorlds" component.
# These patterns can be overriden by a world-specific .apignore using !-prefixed patterns for negation.
# Auto-created folders
__MACOSX
.DS_Store
__pycache__
# Unneeded files
/archipelago.json
/.apignore
/.git
/.gitignore

View File

@@ -224,6 +224,7 @@
height: self.content.texture_size[1] + 80 height: self.content.texture_size[1] + 80
<ScrollBox>: <ScrollBox>:
layout: layout layout: layout
box_height: dp(100)
bar_width: "12dp" bar_width: "12dp"
scroll_wheel_distance: 40 scroll_wheel_distance: 40
do_scroll_x: False do_scroll_x: False
@@ -234,4 +235,11 @@
orientation: "vertical" orientation: "vertical"
spacing: 10 spacing: 10
size_hint_y: None size_hint_y: None
height: self.minimum_height height: max(self.minimum_height, root.box_height)
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -28,7 +28,7 @@
name: Player{number} name: Player{number}
# Used to describe your yaml. Useful if you have multiple files. # Used to describe your yaml. Useful if you have multiple files.
description: {{ yaml_dump("Default %s Template" % game) }} description: {{ yaml_dump("%s Preset for %s" % (preset_name, game)) if preset_name else yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }} game: {{ yaml_dump(game) }}
requires: requires:
@@ -38,11 +38,11 @@ requires:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected. {{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %} {%- endif %}
{%- macro range_option(option) %} {%- macro range_option(option, option_val) %}
# You can define additional values between the minimum and maximum values. # You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }} # Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }} # Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option) %} {%- set data, notes = dictify_range(option, option_val) %}
{%- for entry, default in data.items() %} {%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %} {{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%} {%- endfor -%}
@@ -56,6 +56,10 @@ requires:
{%- for option_key, option in group_options.items() %} {%- for option_key, option in group_options.items() %}
{{ option_key }}: {{ option_key }}:
{%- set option_val = option.default %}
{%- if option_key in preset %}
{%- set option_val = preset[option_key] %}
{%- endif -%}
{%- if option.__doc__ %} {%- if option.__doc__ %}
# {{ cleandoc(option.__doc__) # {{ cleandoc(option.__doc__)
| trim | trim
@@ -69,25 +73,25 @@ requires:
{%- endif -%} {%- endif -%}
{%- if option.range_start is defined and option.range_start is number %} {%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}} {{- range_option(option, option_val) -}}
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option_val or sub_option_name == option_val %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %} {%- if option.name_lookup[option_val] not in option.options and option_val not in option.options %}
{{ yaml_dump(option.default) }}: 50 {{ yaml_dump(option_val) }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %} {%- elif option_val is string %}
{{ yaml_dump(option.default) }}: 50 {{ yaml_dump(option_val) }}: 50
{%- elif option.default is iterable and option.default is not mapping %} {%- elif option_val is iterable and option_val is not mapping %}
{{ option.default | list }} {{ option_val | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | indent(4, first=false) }} {{ yaml_dump(option_val) | indent(4, first=false) }}
{%- endif -%} {%- endif -%}
{{ "\n" }} {{ "\n" }}
{%- endfor %} {%- endfor %}

174
data/optionscreator.kv Normal file
View File

@@ -0,0 +1,174 @@
<VisualRange>:
id: this
spacing: 15
orientation: "horizontal"
slider: slider
tag: tag
MDLabel:
id: tag
text: str(this.option.default) if not isinstance(this.option.default, str) else str(this.option.range_start)
MDSlider:
id: slider
min: this.option.range_start
max: this.option.range_end
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if not isinstance(this.option.default, str) else this.option.range_start
step: 1
step_point_size: 0
MDSliderHandle:
MDSliderValueLabel:
<VisualChoice>:
id: this
text: text
MDButtonText:
id: text
text: this.option.get_option_name(this.option.default if not isinstance(this.option.default, str) else list(this.option.options.values())[0])
theme_text_color: "Primary"
<VisualNamedRange>:
id: this
orientation: "horizontal"
spacing: "10dp"
padding: (0, 0, "10dp", 0)
choice: choice
MDButton:
id: choice
text: text
MDButtonText:
id: text
text: this.option.default.title() if this.option.default in this.option.special_range_names else "Custom"
<VisualFreeText>:
multiline: False
font_size: "15sp"
text: self.option.default if isinstance(self.option.default, str) else ""
theme_height: "Custom"
height: "30dp"
<VisualTextChoice>:
id: this
orientation: "horizontal"
spacing: "5dp"
padding: (0, 0, "10dp", 0)
<VisualToggle>:
id: this
button: button
MDIconButton:
id: button
icon: "checkbox-outline" if this.option.default else "checkbox-blank-outline"
<VisualListSetEntry@ResizableTextField>:
height: "20dp"
<CounterItemValue>:
height: "30dp"
<VisualListSetCounter>:
id: this
scrollbox: scrollbox
add: add
save: save
input: input
focus_behavior: False
MDDialogHeadlineText:
text: getattr(this.option, "display_name", this.name)
MDDialogSupportingText:
text: "Add or Remove Entries"
MDDialogContentContainer:
orientation: "vertical"
spacing: 10
MDBoxLayout:
orientation: "horizontal"
VisualListSetEntry:
id: input
height: "20dp"
MDIconButton:
id: add
icon: "plus"
theme_height: "Custom"
height: "20dp"
on_press: root.validate_add(input)
ScrollBox:
id: scrollbox
size_hint_y: None
adapt_minimum: False
MDButton:
id: save
MDButtonText:
text: "Save Changes"
ContainerLayout:
md_bg_color: app.theme_cls.backgroundColor
MainLayout:
id: main
cols: 3
padding: 3, 5, 0, 3
spacing: "2dp"
ScrollBox:
id: scrollbox
size_hint_x: None
width: "150dp"
MDDivider:
orientation: "vertical"
width: "4dp"
MainLayout:
id: player_layout
rows: 2
spacing: "20dp"
MDBoxLayout:
id: player_options
orientation: "horizontal"
height: "75dp"
size_hint_y: None
padding: ["10dp", "30dp", "10dp", 0]
spacing: "10dp"
ResizableTextField:
id: player_name
multiline: False
MDTextFieldHintText:
text: "Player Name"
MDTextFieldMaxLengthText:
max_text_length: 16
MDBoxLayout:
orientation: "vertical"
spacing: "15dp"
MDLabel:
id: game
text: "Game: None"
pos_hint: {"center_x": 0.5, "center_y": 0.5}
MDButton:
pos_hint: {"center_x": 0.5, "center_y": 0.5}
on_press: app.export_options(self)
theme_width: "Custom"
size_hint_y: 1
size_hint_x: 1
MDButtonText:
pos_hint: {"center_x": 0.5, "center_y": 0.5}
text: "Export Options"
MainLayout:
cols: 1
id: options

View File

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

View File

@@ -41,16 +41,8 @@ http {
# server_name example.com www.example.com; # server_name example.com www.example.com;
keepalive_timeout 5; keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / { 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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
@@ -60,5 +52,15 @@ http {
proxy_pass http://app_server; 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;
}
} }
} }

View File

@@ -15,8 +15,10 @@
# A Link to the Past # A Link to the Past
/worlds/alttp/ @Berserker66 /worlds/alttp/ @Berserker66
# Sudoku (APSudoku) # APQuest
/worlds/apsudoku/ @EmilyV99 # NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Aquaria # Aquaria
/worlds/aquaria/ @tioui /worlds/aquaria/ @tioui
@@ -66,6 +68,9 @@
# DOOM II # DOOM II
/worlds/doom_ii/ @Daivuk @KScl /worlds/doom_ii/ @Daivuk @KScl
# EarthBound
/worlds/earthbound/ @PinkSwitch
# Factorio # Factorio
/worlds/factorio/ @Berserker66 /worlds/factorio/ @Berserker66
@@ -127,6 +132,9 @@
# Mega Man 2 # Mega Man 2
/worlds/mm2/ @Silvris /worlds/mm2/ @Silvris
# Mega Man 3
/worlds/mm3/ @Silvris
# MegaMan Battle Network 3 # MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic /worlds/mmbn3/ @digiholic
@@ -172,8 +180,12 @@
# Sonic Adventure 2 Battle # Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace /worlds/sa2b/ @PoryGone @RaspberrySpace
# Satisfactory
/worlds/satisfactory/ @Jarno458 @budak7273
# Starcraft 2 # Starcraft 2
/worlds/sc2/ @Ziktofel # Note: @Ziktofel acts as a mentor
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
# Super Metroid # Super Metroid
/worlds/sm/ @lordlou /worlds/sm/ @lordlou

View File

@@ -17,7 +17,8 @@ it will not be detailed here.
The client is an intermediary program between the game and the Archipelago server. This can either be a direct The client is an intermediary program between the game and the Archipelago server. This can either be a direct
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. Additional help with specific game
engines and rom formats can be found in the #ap-modding-help channel in the [Discord](https://archipelago.gg/discord).
### Hard Requirements ### Hard Requirements
@@ -86,7 +87,8 @@ 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 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 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 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 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 during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
@@ -139,8 +141,8 @@ if possible.
* An implementation of * An implementation of
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473) [get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true * By default, this function chooses any item name from `item_name_to_id`, which may include items you consider
filler items. "non-repeatable".
* An `options_dataclass` defining the options players have available to them * An `options_dataclass` defining the options players have available to them
* This should be accompanied by a type hint for `options` with the same class name * This should be accompanied by a type hint for `options` with the same class name
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220) * A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)

View File

@@ -41,16 +41,18 @@ There are also the following optional fields:
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`, 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). which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component), ["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. which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build apworlds" Launcher Component ### "Build APWorlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`, In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
and add `archipelago.json` manifest files to them. 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). 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 The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`. `version` and `compatible_version`.
The component can also be called from the command line to allow for specifying a certain list of worlds to build.
For example, running `Launcher.py "Build APWorlds" -- "Game Name"` will build only the game called `Game Name`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over. If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this: So, a world folder with an `archipelago.json` that looks like this:
@@ -79,10 +81,26 @@ will be packaged into an `.apworld` with a manifest file inside of it that looks
This is the recommended workflow for packaging your world to an `.apworld`. This is the recommended workflow for packaging your world to an `.apworld`.
## Extra Data ### .apignore Exclusions
The zip can contain arbitrary files in addition what was specified above. By default, any additional files inside of the world folder will be packaged into the resulting `.apworld` archive and
can then be read by the world. However, if there are any other files that aren't needed in the resulting `.apworld`, you
can automatically prevent the build component from including them by specifying them in a file called `.apignore` inside
the root of the world folder.
The `.apignore` file selects files in the same way as the `.gitignore` format with patterns separated by line describing
which files to ignore. For example, an `.apignore` like this:
```gitignore
*.iso
scripts/
!scripts/needed.py
```
would ignore any `.iso` files and anything in the scripts folder except for `scripts/needed.py`.
Some exclusions are made by default for all worlds such as `__pycache__` folders. These are listed in the
`GLOBAL.apignore` file inside of the `data` directory.
## Caveats ## Caveats

View File

@@ -6,6 +6,49 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
--- ---
### I've never added a game to Archipelago before. Should I start with the APWorld or the game client?
Strictly speaking, this is a false dichotomy: we do *not* recommend doing 100% of client work before the APWorld,
or 100% of APWorld work before the client. It's important to iterate on both parts and test them together.
However, the early iterations tend to be very similar for most games,
so the typical recommendation for first-time AP developers is:
- Start with a proof-of-concept for [the game client](adding%20games.md#client)
- Figure out how to interface with the game. Whether that means "modding" the game, or patching a ROM file,
or developing a separate client program that edits the game's memory, or some other technique.
- Figure out how to give items and detect locations in the actual game. Not every item and location,
just one of each major type (e.g. opening a chest vs completing a sidequest) to prove all the items and locations
you want can actually be implemented.
- Figure out how to make a websocket connection to an AP server, possibly using a client library (see [Network Protocol](<network%20protocol.md>).
To make absolutely sure this part works, you may want to test the connection by generating a multiworld
with a different game, then making your client temporarily pretend to be that other game.
- Next, make a "trivial" APWorld, i.e. an APWorld that always generates the same items and locations
- If you've never done this before, likely the fastest approach is to copy-paste [APQuest](<../worlds/apquest>), and read the many
comments in there until you understand how to edit the items and locations.
- Then you can do your first "end-to-end test": generate a multiworld using your APWorld, [run a local server](<running%20from%20source.md>)
to host it, connect to that local server from your game client, actually check a location in the game,
and finally make sure the client successfully sent that location check to the AP server
as well as received an item from it.
That's about where general recommendations end. What you should do next will depend entirely on your game
(e.g. implement more items, write down logic rules, add client features, prototype a tracker, etc).
If you're not sure, then this would be a good time to re-read [Adding Games](<adding%20games.md>), and [World API](<world%20api.md>).
There are a few assumptions in this recommendation worth stating explicitly, namely:
- If something you want to do is infeasible, you want to find out that it's infeasible as soon as possible, before
you write a bunch of code assuming it could be done. That's why we recommend starting with the game client.
- Getting an APWorld to generate whatever items/locations you want is always feasible, since items/locations are
little more than id numbers and name strings during generation.
- You generally want to get to an "end-to-end playable" prototype quickly. On top of all the technical challenges these
docs describe, it's also important to check that a randomizer is *fun to play*, and figure out what features would be
essential for a public release.
- A first-time world developer may or may not be deeply familiar with Archipelago, but they're almost certainly familiar
with the game they want to randomize. So judging whether your game client is working correctly might be significantly
easier than judging if your APWorld is working.
---
### My game has a restrictive start that leads to fill errors ### My game has a restrictive start that leads to fill errors
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
@@ -140,3 +183,58 @@ So when the game itself does not follow this assumption, the options are:
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections - For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic - For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that - Decide that resetting the save file is part of the game's logic, and warn players about that
---
### What are "local" vs "remote" items, and what are the pros and cons of each?
First off, these terms can be misleading. Since the whole point of a multi-game multiworld randomizer is that some items
are going to be placed in other slots (unless there's only one slot), the choice isn't really "local vs remote";
it's "mixed local/remote vs all remote". You have to get "remote items" working to be an AP implementation at all, and
it's often simpler to handle every item/location the same way, so you generally shouldn't worry about "local items"
until you've finished more critical features.
Next, "local" and "remote" items confusingly refer to multiple concepts, so it's important to clearly separate them:
- Whether an item happens to get placed in the same slot it originates from, or a different slot. I'll call these
"locally placed" and "remotely placed" items.
- Whether an AP client implements location checking for locally placed items by skipping the usual AP server roundtrip
(i.e. sending [LocationChecks](<network%20protocol.md#locationchecks>)
then receiving [ReceivedItems](<network%20protocol.md#receiveditems>)
) and directly giving the item to the player, or by doing the AP server roundtrip regardless. I'll call these
"locally implemented" items and "remotely implemented" items.
- Locally implementing items requires the AP client to know what the locally placed items were without asking an AP
server (or else you'd effectively be doing remote items with extra steps). Typically, it gets that information from
a patch file, which is one reason why games that already need a patch file are more likely to choose local items.
- If items are remotely implemented, the AP client can use [location scouts](<network%20protocol.md#LocationScouts>)
to learn what items are placed on what locations. Features that require this information are sometimes mistakenly
assumed to require locally implemented items, but location scouts work just as well as patch file data.
- [The `items_handling` bitflags in the Connect packet](<network%20protocol.md#items_handling-flags>).
AP clients with remotely implemented items will typically set all three flags, including "from your own world".
Clients with locally implemented items might set only the "from other worlds" flag.
- Whether a local items client sets the "starting inventory" flag likely depends on other details. For example, if a ROM
is being patched, and starting inventory can be added to that patch, then it makes sense to leave the flag unset.
When people talk about "local vs remote items" as a choice that world devs have to make, they mean deciding whether
your client will locally or remotely implement the items which happen to be locally placed (or make both
implementations, or let the player choose an implementation).
Theoretically, the biggest benefit of "local items" is that it allows a solo (single slot) multiworld to be played
entirely offline, with no AP server, from start to finish. This is similar to a "standalone"/non-AP randomizer,
except that you still get AP's player options, generation, etc. for free.
For some games, there are also technical constraints that make certain items easier to implement locally,
or less glitchy when implemented locally, as long as you're okay with never allowing these items to be placed remotely
(or offering the player even more options).
The main downside (besides more implementation work) is that "local items" can't support "same slot co-op".
That's when two players on two different machines connect to the same slot and play together.
This only works if both players receive all the items for that slot, including ones found by the other player,
which requires those items to be implemented remotely so the AP server can send them to all of that slot's clients.
So to recap:
- (All) remote items is often the simplest choice, since you have to implement remote items anyway.
- Remote items enable same slot co-op.
- Local items enable solo offline play.
- If you want to support both solo offline play and same slot co-op,
you might need to expose local vs remote items as an option to the player.

View File

@@ -20,7 +20,7 @@ game contributions:
It is recommended that automated github actions are turned on in your fork to have github run unit tests after It is recommended that automated github actions are turned on in your fork to have github run unit tests after
pushing. pushing.
You can turn them on here: You can turn them on here:
![Github actions example](./img/github-actions-example.png) ![Github actions example](/docs/img/github-actions-example.png)
* **When reviewing PRs, please leave a message about what was done.** * **When reviewing PRs, please leave a message about what was done.**
We don't have full test coverage, so manual testing can help. We don't have full test coverage, so manual testing can help.

View File

@@ -225,7 +225,7 @@ Sent to clients after a client requested this message be sent to them, more info
| games | list\[str\] | Optional. Game names this message is targeting | | games | list\[str\] | Optional. Game names this message is targeting |
| slots | list\[int\] | Optional. Player slot IDs that this message is targeting | | slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting | | tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
| data | dict | The data in the [Bounce](#Bounce) package copied | | data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
### InvalidPacket ### InvalidPacket
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for. Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
@@ -425,7 +425,7 @@ the server will forward the message to all those targets to which any one requir
| games | list\[str\] | Optional. Game names that should receive this message | | games | list\[str\] | Optional. Game names that should receive this message |
| slots | list\[int\] | Optional. Player IDs that should receive this message | | slots | list\[int\] | Optional. Player IDs that should receive this message |
| tags | list\[str\] | Optional. Client tags that should receive this message | | tags | list\[str\] | Optional. Client tags that should receive this message |
| data | dict | Any data you want to send | | data | dict | Optional. Any data you want to send |
### Get ### Get
Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package. Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package.
@@ -647,6 +647,16 @@ class Version(NamedTuple):
build: int build: int
``` ```
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
```
"version": {
"class": "Version",
"build": X,
"major": Y,
"minor": Z
}
```
### SlotType ### SlotType
An enum representing the nature of a slot. An enum representing the nature of a slot.

View File

@@ -269,7 +269,8 @@ placed on them.
### PriorityLocations ### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
the pool. the pool. Progression items without a deprioritized flag will be used first when filling priority_locations. Progression items with
a deprioritized flag will be used next.
### ItemLinks ### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between Allows users to share their item pool with other players. Currently item links are per game. A link of one game between

482
docs/rule builder.md Normal file
View File

@@ -0,0 +1,482 @@
# 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:
- Rule classes that avoid all the common pitfalls
- Logic optimization
- Automatic result caching (opt-in)
- Serialization/deserialization
- Human-readable logic explanations for players
## Overview
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.
## 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.
```python
# In your world's create_regions method
location = MyWorldLocation(...)
self.set_rule(location, Has("A Big Gun"))
```
The rule builder comes with a number of rules by default:
- `True_`: Always returns true
- `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)
- `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
- `HasAllCounts`: Checks that the player has all of the counts for the given items
- `HasAnyCount`: Checks that the player has any of the counts for the given items
- `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
- `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:
```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`.
### Assigning rules
When assigning the rule you must use the `set_rule` helper to correctly resolve and register the rule.
```python
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`.
```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`.
You can also set a rule for your world's completion condition:
```python
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`.
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
- `eq`: `==`
- `ne`: `!=`
- `gt`: `>`
- `lt`: `<`
- `ge`: `>=`
- `le`: `<=`
- `contains`: `in`
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.
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
```python
rule = (
Has("Red switch", options=[OptionFilter(SwitchRando, 1)])
| CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)])
)
```
To add an extra logic requirement on the easiest difficulty which is ignored for other difficulties:
```python
rule = (
# ...the rest of the logic
& Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)], filtered_resolution=True)
)
```
If you would like to provide option filters when reusing or composing rules, you can use the `Filtered` helper rule:
```python
common_rule = Has("A") | HasAny("B", "C")
...
rule = (
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:
```python
common_rule = Has("A")
easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)]
common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
## Enabling caching
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.
### 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.
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):
item_mapping = {
"Currency x10": "Currency",
"Currency x50": "Currency",
"Currency x100": "Currency",
"Currency x500": "Currency",
}
```
## 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 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:
```python
@dataclasses.dataclass()
class CanGoal(Rule["MyWorld"], game="My Game"):
@override
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
# caching_enabled only needs to be passed in when your world inherits from CachedRuleBuilderWorld
return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=True)
class Resolved(Rule.Resolved):
goal: int
@override
def _evaluate(self, state: CollectionState) -> bool:
return state.has("McGuffin", self.player, count=self.goal)
@override
def item_dependencies(self) -> dict[str, set[int]]:
# this function is only required if you have caching enabled
return {"McGuffin": {id(self)}}
@override
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
# this method can be overridden to display custom explanations
return [
{"type": "text", "text": "Goal with "},
{"type": "color", "color": "green" if state and self(state) else "salmon", "text": str(self.goal)},
{"type": "text", "text": " McGuffins"},
]
```
Your custom rule can also resolve to builtin rules instead of needing to define your own:
```python
@dataclasses.dataclass()
class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
if world.some_precalculated_bool:
return Has("Item 1").resolve(world)
if world.options.some_option:
return CanReachRegion("Region 1").resolve(world)
return False_().resolve(world)
```
### 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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
item_name: str
@override
def item_dependencies(self) -> dict[str, set[int]]:
return {self.item_name: {id(self)}}
```
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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
region_name: str
@override
def region_dependencies(self) -> dict[str, set[int]]:
return {self.region_name: {id(self)}}
```
The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules define this function already.
### 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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
location_name: str
@override
def location_dependencies(self) -> dict[str, set[int]]:
return {self.location_name: {id(self)}}
```
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.
```python
@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
class Resolved(Rule.Resolved):
entrance_name: str
@override
def entrance_dependencies(self) -> dict[str, set[int]]:
return {self.entrance_name: {id(self)}}
```
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.
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"):
class Resolved(Rule.Resolved):
@override
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
has_item = state and state.has("growth spurt", self.player)
color = "yellow"
start = "You must be "
if has_item:
start = "You are "
color = "green"
elif state is not None:
start = "You are not "
color = "salmon"
return [
{"type": "text", "text": start},
{"type": "color", "color": color, "text": "THIS"},
{"type": "text", "text": " tall to beat the game"},
]
@override
def explain_str(self, state: CollectionState | None = None) -> str:
if state is None:
return str(self)
if state.has("growth spurt", self.player):
return "You ARE this tall and can beat the game"
return "You are not THIS tall and cannot beat the game"
@override
def __str__(self) -> str:
return "You must be THIS tall to beat the 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.
- `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.
- 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.
## 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 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
{
"rule": "Has",
"options": [],
"args": {
"item_name": "Some item",
"count": 1,
},
}
```
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
{
"rule": "And",
"options": [],
"children": [
..., # each serialized rule
]
}
```
A full example is as follows:
```python
rule = And(
Has("a", options=[OptionFilter(ToggleOption, 0)]),
Or(Has("b", count=2), CanReachRegion("c"), options=[OptionFilter(ToggleOption, 1)]),
)
assert rule.to_dict() == {
"rule": "And",
"options": [],
"children": [
{
"rule": "Has",
"options": [
{
"option": "worlds.my_world.options.ToggleOption",
"value": 0,
"operator": "eq",
},
],
"args": {
"item_name": "a",
"count": 1,
},
},
{
"rule": "Or",
"options": [
{
"option": "worlds.my_world.options.ToggleOption",
"value": 1,
"operator": "eq",
},
],
"children": [
{
"rule": "Has",
"options": [],
"args": {
"item_name": "b",
"count": 2,
},
},
{
"rule": "CanReachRegion",
"options": [],
"args": {
"region_name": "c",
},
},
],
},
],
}
```
### Custom serialization
To define a different format for your custom rules, override the `to_dict` function:
```python
class BasicLogicRule(Rule, game="My Game"):
items = ("one", "two")
def to_dict(self) -> dict[str, Any]:
# Return whatever format works best for you
return {
"logic": "basic",
"items": self.items,
}
```
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"):
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self:
items = data.get("items", ())
return cls(*items)
```
## APIs
This section is provided for reference, refer to the above sections for examples.
### World API
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
- `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`
#### CachedRuleBuilderWorld Properties
The following property is only available when inheriting from `CachedRuleBuilderWorld`
- `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name
### Rule API
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`
- `__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
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules

View File

@@ -7,10 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
## General ## General
What you'll need: What you'll need:
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version * [Python 3.11.9 or newer but less than 3.14](https://www.python.org/downloads/), not the Windows Store version
* On Windows, please consider only using the latest supported version in production environments since security * On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available. updates for older versions are not easily available.
* Python 3.13.x is currently the newest supported version
* pip: included in downloads from python.org, separate in many Linux distributions * pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler * Matching C compiler
* possibly optional, read operating system specific sections * possibly optional, read operating system specific sections
@@ -53,6 +52,32 @@ Recommended steps
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md). Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
## Linux
If your Linux distribution ships a compatible Python version (see [General](#general)) and pip, you can use that,
otherwise you may need to install Python from a 3rd party. Refer to documentation of your Linux distribution.
Installing a C compiler is usually optional. The package is typically named `gcc`, sometimes another package with the
base build tools may be required, i.e. `build-essential` (Debian/Ubuntu) or `base-devel` (Arch).
After getting the source code, it is strongly recommended to create a
[venv](https://docs.python.org/3/tutorial/venv.html) (Virtual Environment)
by hand or using an IDE, such as PyCharm, because Archipelago requires specific versions of Python packages.
Run `python ModuleUpdate.py` in the project root to install packages, run `python Launcher.py` to run the Launcher.
### Building
Builds contain (almost) all dependencies to run Archipelago on any Linux distribution that is as new or newer than the
one it was built on. Beware that currently only the oldest Ubuntu LTS available in GitHub actions is supported for that.
This means the easiest way to generate a build is by running the `Build` action from GitHub actions instead of building
locally. If you still want to, e.g. for local testing, you can by running
`python setup.py build_exe` to generate a binary distribution of Archipelago in `build/`. Or to generate an AppImage
first generate the binary distribution and then run `python setup.py bdist_appimage` to populate `dist/`. You need to
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 ## 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 Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an

View File

@@ -47,21 +47,27 @@
## HTML ## HTML
* Indent with 2 spaces for new code. * Indent with 4 spaces for new code.
* kebab-case for ids and classes. * kebab-case for ids and classes.
* Avoid using on* attributes (onclick, etc.).
## CSS ## CSS / SCSS
* Indent with 2 spaces for new code. * Indent with 4 spaces for new code.
* `{` on the same line as the selector. * `{` on the same line as the selector.
* No space between selector and `{`. * Space between selector and `{`.
## JS ## JS
* Indent with 2 spaces. * Indent with 4 spaces.
* Indent `case` inside `switch ` with 2 spaces. * Indent `case` inside `switch ` with 4 spaces.
* Use single quotes. * Prefer double quotation marks (`"`).
* Semicolons are required after every statement. * Semicolons are required after every statement.
* Use [IIFEs](https://developer.mozilla.org/docs/Glossary/IIFE) to avoid polluting global scope.
* Prefer to use [defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#defer)
in script tags, which retains order of execution but does not block.
* Avoid `<script async ...` in most cases, see [async and defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#async_and_defer).
* Use addEventListener.
## KV ## KV

View File

@@ -4,7 +4,7 @@ Archipelago has a rudimentary API that can be queried by endpoints. The API is a
The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>` The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>`
The returned data will be formated in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable) The returned data will be formatted in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
Current endpoints: Current endpoints:
- Datapackage API - Datapackage API
@@ -24,13 +24,21 @@ Current endpoints:
- [`/get_rooms`](#getrooms) - [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds) - [`/get_seeds`](#getseeds)
## API Data Caching
To reduce the strain on an Archipelago WebHost, many API endpoints will cache their data and only poll new data in timed intervals. Each endpoint has their own caching time related to the type of data being served. More dynamic data is refreshed more frequently, while static data is cached for longer.
Each API endpoint will have their "Cache timer" listed under their definition (if any).
API calls to these endpoints should not be faster than the listed timer. This will result in wasted processing for your client and (more importantly) the Archipelago WebHost, as the data will not be refreshed by the WebHost until the internal timer has elapsed.
## Datapackage Endpoints ## Datapackage Endpoints
These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations. These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations.
### `/datapackage` ### `/datapackage`
<a name="datapackage"></a> <a name="datapackage"></a>
Fetches the current datapackage from the WebHost. Fetches the current datapackage from the WebHost.
**Cache timer: None**
You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago. You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago.
Each game will have: Each game will have:
- A checksum `checksum` - A checksum `checksum`
@@ -40,7 +48,7 @@ Each game will have:
- Location name to AP ID dict `location_name_to_id` - Location name to AP ID dict `location_name_to_id`
Example: Example:
``` ```json
{ {
"games": { "games": {
... ...
@@ -76,7 +84,10 @@ Example:
### `/datapackage/<string:checksum>` ### `/datapackage/<string:checksum>`
<a name="datapackagestringchecksum"></a> <a name="datapackagestringchecksum"></a>
Fetches a single datapackage by checksum.
Fetches a single datapackage by checksum.
**Cache timer: None**
Returns a dict of the game's data with: Returns a dict of the game's data with:
- A checksum `checksum` - A checksum `checksum`
- A dict of item groups `item_name_groups` - A dict of item groups `item_name_groups`
@@ -88,10 +99,13 @@ Its format will be identical to the whole-datapackage endpoint (`/datapackage`),
### `/datapackage_checksum` ### `/datapackage_checksum`
<a name="datapackagechecksum"></a> <a name="datapackagechecksum"></a>
Fetches the checksums of the current static datapackages on the WebHost.
Fetches the checksums of the current static datapackages on the WebHost.
**Cache timer: None**
You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games. You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games.
Example: Example:
``` ```json
{ {
... ...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e", "Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
@@ -108,6 +122,7 @@ These endpoints are used internally for the WebHost to generate games and valida
<a name="generate"></a> <a name="generate"></a>
Submits a game to the WebHost for generation. Submits a game to the WebHost for generation.
**This endpoint only accepts a POST HTTP request.** **This endpoint only accepts a POST HTTP request.**
**Cache timer: None**
There are two ways to submit data for generation: With a file and with JSON. There are two ways to submit data for generation: With a file and with JSON.
@@ -116,7 +131,7 @@ Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/ge
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response)) If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
Example using the python requests library: Example using the python requests library:
``` ```python
file = {'file': open('Games.zip', 'rb')} file = {'file': open('Games.zip', 'rb')}
req = requests.post("https://archipelago.gg/api/generate", files=file) req = requests.post("https://archipelago.gg/api/generate", files=file)
``` ```
@@ -127,7 +142,7 @@ Finally, submit a POST request to the `/generate` endpoint.
If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response)) If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response))
Example using the python requests library: Example using the python requests library:
``` ```python
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},} data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
weights={"weights": data} weights={"weights": data}
req = requests.post("https://archipelago.gg/api/generate", json=weights) req = requests.post("https://archipelago.gg/api/generate", json=weights)
@@ -143,7 +158,7 @@ Upon successful generation, you'll be sent a JSON dict response detailing the ge
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status)) - The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
Example: Example:
``` ```json
{ {
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463", "detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
"encoded": "GYePFlpYS3aqt9a_OL6UYw", "encoded": "GYePFlpYS3aqt9a_OL6UYw",
@@ -167,12 +182,14 @@ If the generation detects a issue in generation, you'll be sent a dict with two
- Detailed issue in `detail` - Detailed issue in `detail`
In the event of an unhandled server exception, you'll be provided a dict with a single key `text`: In the event of an unhandled server exception, you'll be provided a dict with a single key `text`:
- Exception, `Uncought Exception: <error>` with a 500 status code - Exception, `Uncaught Exception: <error>` with a 500 status code
### `/status/<suuid:seed>` ### `/status/<suuid:seed>`
<a name="status"></a> <a name="status"></a>
Retrieves the status of the seed's generation. Retrieves the status of the seed's generation.
This endpoint will return a dict with a single key-vlaue pair. The key will always be `text` **Cache timer: None**
This endpoint will return a dict with a single key-value pair. The key will always be `text`
The value will tell you the status of the generation: The value will tell you the status of the generation:
- Generation was completed: `Generation done` with a 201 status code - Generation was completed: `Generation done` with a 201 status code
- Generation request was not found: `Generation not found` with a 404 status code - Generation request was not found: `Generation not found` with a 404 status code
@@ -184,6 +201,8 @@ Endpoints to fetch information of the active WebHost room with the supplied room
### `/room_status/<suuid:room_id>` ### `/room_status/<suuid:room_id>`
<a name="roomstatus"></a> <a name="roomstatus"></a>
**Cache timer: None**
Will provide a dict of room data with the following keys: Will provide a dict of room data with the following keys:
- Tracker SUUID (`tracker`) - Tracker SUUID (`tracker`)
- A list of players (`players`) - A list of players (`players`)
@@ -192,10 +211,10 @@ Will provide a dict of room data with the following keys:
- Last activity timestamp (`last_activity`) - Last activity timestamp (`last_activity`)
- The room timeout counter (`timeout`) - The room timeout counter (`timeout`)
- A list of downloads for files required for gameplay (`downloads`) - A list of downloads for files required for gameplay (`downloads`)
- Each item is a dict containings the download URL and slot (`slot`, `download`) - Each item is a dict containing the download URL and slot (`slot`, `download`)
Example: Example:
``` ```json
{ {
"downloads": [ "downloads": [
{ {
@@ -244,7 +263,7 @@ Example:
] ]
], ],
"timeout": 7200, "timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171" "tracker": "2gVkMQgISGScA8wsvDZg5A"
} }
``` ```
@@ -254,17 +273,27 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
### `/tracker/<suuid:tracker>` ### `/tracker/<suuid:tracker>`
<a name=tracker></a> <a name=tracker></a>
**Cache timer: 60 seconds**
Will provide a dict of tracker data with the following keys: Will provide a dict of tracker data with the following keys:
- Each player's current alias (`aliases`) - A list of players current alias data (`aliases`)
- Will return the name if there is none - Each item containing a dict with, their alias `alias`, their player number `player`, and their team `team`
- A list of items each player has received as a NetworkItem (`player_items_received`) - `alias` will return `null` if there is no alias set
- A list of items each player has received as a [NetworkItem](network%20protocol.md#networkitem) (`player_items_received`)
- Each item containing a dict with, a list of NetworkItems `items`, their player number `player`, their team `team`
- A list of checks done by each player as a list of the location id's (`player_checks_done`) - A list of checks done by each player as a list of the location id's (`player_checks_done`)
- The total number of checks done by all players (`total_checks_done`) - Each item containing a dict with, a list of checked location id's `locations`, their player number `player`, and their team `team`
- Hints that players have used or received (`hints`) - A list of the total number of checks done by all players (`total_checks_done`)
- The time of last activity of each player in RFC 1123 format (`activity_timers`) - Each item will contain a dict with, the total checks done `checks_done`, and the team `team`
- The time of last active connection of each player in RFC 1123 format (`connection_timers`) - A list of [Hints](network%20protocol.md#hint) data that players have used or received (`hints`)
- The current client status of each player (`player_status`) - Each item containing a dict containing, a list of hint data `hints`, the player number `player`, and their team `team`
- A list containing the last activity time for each player, formatted in RFC 1123 format (`activity_timers`)
- Each item containing, last activity time `time`, their player number `player`, and their team `team`
- A list containing the last connection time for each player, formatted in RFC 1123 format (`connection_timers`)
- Each item containing, the time of their last connection `time`, their player number `player`, and their team `team`
- A list of the current [ClientStatus](network%20protocol.md#clientstatus) of each player (`player_status`)
- Each item will contain, their status `status`, their player number `player`, and their team `team`
Example: Example:
```json ```json
@@ -279,7 +308,12 @@ Example:
"team": 0, "team": 0,
"player": 2, "player": 2,
"alias": "Slot_Name_2" "alias": "Slot_Name_2"
} },
{
"team": 0,
"player": 3,
"alias": null
},
], ],
"player_items_received": [ "player_items_received": [
{ {
@@ -378,13 +412,21 @@ Example:
### `/static_tracker/<suuid:tracker>` ### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a> <a name=statictracker></a>
**Cache timer: 300 seconds**
Will provide a dict of static tracker data with the following keys: Will provide a dict of static tracker data with the following keys:
- item_link groups and their players (`groups`) - A list of item_link groups and their member players (`groups`)
- The datapackage hash for each game (`datapackage`) - Each item containing a dict with, the slot registering the group `slot`, the item_link name `name`, and a list of members `members`
- A dict of datapackage hashes for each game (`datapackage`)
- Each item is a named dict of the game's name.
- Each game contains two keys, the datapackage's checksum hash `checksum`, and the version `version`
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary - This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
- The number of checks found vs. total checks available per player (`player_locations_total`) - A list of number of checks found vs. total checks available per player (`player_locations_total`)
- Each list item contains a dict with three keys, the total locations for that slot `total_locations`, their player number `player`, and their team `team`
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks). - Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
- The game each player is playing (`player_game`)
- Provided as a list of objects with `team`, `player`, and `game`.
Example: Example:
```json ```json
@@ -409,10 +451,10 @@ Example:
], ],
"datapackage": { "datapackage": {
"Archipelago": { "Archipelago": {
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb", "checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb"
}, },
"The Messenger": { "The Messenger": {
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b", "checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b"
} }
}, },
"player_locations_total": [ "player_locations_total": [
@@ -427,12 +469,29 @@ Example:
"total_locations": 20 "total_locations": 20
} }
], ],
"player_game": [
{
"team": 0,
"player": 1,
"game": "Archipelago"
},
{
"team": 0,
"player": 2,
"game": "The Messenger"
}
]
} }
``` ```
### `/slot_data_tracker/<suuid:tracker>` ### `/slot_data_tracker/<suuid:tracker>`
<a name=slotdatatracker></a> <a name=slotdatatracker></a>
Will provide a list of each player's slot_data. Will provide a list of each player's slot_data.
**Cache timer: 300 seconds**
Each list item will contain a dict with the player's data:
- player slot number `player`
- A named dict `slot_data` containing any set slot data for that player
Example: Example:
```json ```json
@@ -460,6 +519,8 @@ User endpoints can get room and seed details from the current session tokens (co
### `/get_rooms` ### `/get_rooms`
<a name="getrooms"></a> <a name="getrooms"></a>
Retreives a list of all rooms currently owned by the session token. Retreives a list of all rooms currently owned by the session token.
**Cache timer: None**
Each list item will contain a dict with the room's details: Each list item will contain a dict with the room's details:
- Room SUUID (`room_id`) - Room SUUID (`room_id`)
- Seed SUUID (`seed_id`) - Seed SUUID (`seed_id`)
@@ -470,25 +531,25 @@ Each list item will contain a dict with the room's details:
- Room tracker SUUID (`tracker`) - Room tracker SUUID (`tracker`)
Example: Example:
``` ```json
[ [
{ {
"creation_time": "Fri, 18 Apr 2025 19:46:53 GMT", "creation_time": "Fri, 18 Apr 2025 19:46:53 GMT",
"last_activity": "Fri, 18 Apr 2025 21:16:02 GMT", "last_activity": "Fri, 18 Apr 2025 21:16:02 GMT",
"last_port": 52122, "last_port": 52122,
"room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a", "room_id": "0D30FgQaRcWivFsw9o8qzw",
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6", "seed_id": "TFjiarBgTsCj5-Jbe8u33A",
"timeout": 7200, "timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171" "tracker": "52BycvJhRe6knrYH8v4bag"
}, },
{ {
"creation_time": "Fri, 18 Apr 2025 20:36:42 GMT", "creation_time": "Fri, 18 Apr 2025 20:36:42 GMT",
"last_activity": "Fri, 18 Apr 2025 20:36:46 GMT", "last_activity": "Fri, 18 Apr 2025 20:36:46 GMT",
"last_port": 56884, "last_port": 56884,
"room_id": "14465c05-d08e-4d28-96bd-916f994609d8", "room_id": "LMCFchESSNyuqcY3GxkhwA",
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb", "seed_id": "CENtJMXCTGmkIYCzjB5Csg",
"timeout": 7200, "timeout": 7200,
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c" "tracker": "2gVkMQgISGScA8wsvDZg5A"
} }
] ]
``` ```
@@ -496,6 +557,8 @@ Example:
### `/get_seeds` ### `/get_seeds`
<a name="getseeds"></a> <a name="getseeds"></a>
Retreives a list of all seeds currently owned by the session token. Retreives a list of all seeds currently owned by the session token.
**Cache timer: None**
Each item in the list will contain a dict with the seed's details: Each item in the list will contain a dict with the seed's details:
- Seed SUUID (`seed_id`) - Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`) - Creation timestamp (`creation_time`)
@@ -503,7 +566,7 @@ Each item in the list will contain a dict with the seed's details:
- Each item in the list will contain a list of the slot name and game - Each item in the list will contain a list of the slot name and game
Example: Example:
``` ```json
[ [
{ {
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT", "creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
@@ -529,7 +592,7 @@ Example:
"Ocarina of Time" "Ocarina of Time"
] ]
], ],
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6" "seed_id": "CENtJMXCTGmkIYCzjB5Csg"
}, },
{ {
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT", "creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
@@ -551,7 +614,7 @@ Example:
"Archipelago" "Archipelago"
] ]
], ],
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb" "seed_id": "TFjiarBgTsCj5-Jbe8u33A"
} }
] ]
``` ```

View File

@@ -225,7 +225,10 @@ and has a classification. The name needs to be unique within each game and must
letter or symbol). The ID needs to be unique across all locations within the game. letter or symbol). The ID needs to be unique across all locations within the game.
Locations and items can share IDs, and locations can share IDs with other games' locations. Locations and items can share IDs, and locations can share IDs with other games' locations.
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved. World-specific IDs **must** be in the range 1 to 2<sup>53</sup>-1 (the largest integer that is "[safe](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER#description)"
to store in a 64-bit float, and thus all popular programming languages can handle). IDs ≤ 0 are global and reserved.
It's **recommended** to keep your IDs in the range 1 to 2<sup>31</sup>-1,
so only 32-bit integers are needed to hold your IDs.
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`. Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
@@ -488,9 +491,10 @@ class MyGameWorld(World):
base_id = 1234 base_id = 1234
# instead of dynamic numbering, IDs could be part of data # instead of dynamic numbering, IDs could be part of data
# The following two dicts are required for the generation to know which # The following two dicts are required for the generation to know which items exist.
# items exist. They could be generated from json or something else. They can # They can be generated with arbitrary code during world load, but keep in mind that
# include events, but don't have to since events will be placed manually. # 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 item_name_to_id = {name: id for
id, name in enumerate(mygame_items, base_id)} id, name in enumerate(mygame_items, base_id)}
location_name_to_id = {name: id for location_name_to_id = {name: id for
@@ -767,6 +771,7 @@ class MyGameState(LogicMixin):
new_state.mygame_defeatable_enemies = { new_state.mygame_defeatable_enemies = {
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items() player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
} }
return new_state
``` ```
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules. After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.

View File

@@ -186,9 +186,20 @@ class ERPlacementState:
self.pairings = [] self.pairings = []
self.world = world self.world = world
self.coupled = coupled self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup 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 @property
def placed_regions(self) -> set[Region]: def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player] 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].remove(source_exit)
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits) copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
copied_state.update_reachable_regions(self.world.player) 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 # test that at there are newly reachable randomized exits that are ACTUALLY reachable
available_randomized_exits = copied_state.blocked_connections[self.world.player] available_randomized_exits = copied_state.blocked_connections[self.world.player]
for _exit in available_randomized_exits: for _exit in available_randomized_exits:
@@ -402,7 +413,7 @@ def randomize_entrances(
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance) placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
# propagate new connections # propagate new connections
er_state.collection_state.update_reachable_regions(world.player) er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements() er_state.collection_state.sweep_for_advancements(world.get_locations())
if on_connect: if on_connect:
change = on_connect(er_state, placed_exits, paired_entrances) change = on_connect(er_state, placed_exits, paired_entrances)
if change: if change:
@@ -525,7 +536,7 @@ def randomize_entrances(
running_time = time.perf_counter() - start_time running_time = time.perf_counter() - start_time
if running_time > 1.0: if running_time > 1.0:
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}," logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}, "
f"named {world.multiworld.player_name[world.player]}") f"named {world.multiworld.player_name[world.player]}")
return er_state return er_state

586
host.yaml Normal file
View File

@@ -0,0 +1,586 @@
general_options:
# Where to place output files
output_path: "output"
# Options for MultiServer
# Null means nothing, for the server this means to default the value
# These overwrite command line arguments!
server_options:
host: null
port: 38281
password: null
multidata: null
savefile: null
disable_save: false
loglevel: "info"
logtime: false
# Allows for clients to log on and manage the server. If this is null, no remote administration is possible.
server_password: null
# Disallow !getitem
disable_item_cheat: false
# Client hint system
# Points given to a player for each acquired item in their world
location_check_points: 1
# Relative point cost to receive a hint via !hint for players
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint,
# for a total of 5
hint_cost: 10
# Release modes
# A Release sends out the remaining items *from* a world that releases
# "disabled" -> clients can't release,
# "enabled" -> clients can always release
# "auto" -> automatic release on goal completion
# "auto-enabled" -> automatic release on goal completion and manual release is also enabled
# "goal" -> release is allowed after goal completion
release_mode: "auto"
# Collect modes
# A Collect sends the remaining items *to* a world that collects
# "disabled" -> clients can't collect,
# "enabled" -> clients can always collect
# "auto" -> automatic collect on goal completion
# "auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
# "goal" -> collect is allowed after goal completion
collect_mode: "auto"
# Remaining modes
# !remaining handling, that tells a client which items remain in their pool
# "enabled" -> Client can always ask for remaining items
# "disabled" -> Client can never ask for remaining items
# "goal" -> Client can ask for remaining items after goal completion
remaining_mode: "goal"
# Countdown modes
# Determines whether or not a player can initiate a countdown with !countdown
# Note that /countdown is always available to the host.
# "enabled" -> Client can always initiate a countdown with !countdown.
# "disabled" -> Client can never initiate a countdown with !countdown.
# "auto" -> !countdown will be available for any room with less than 30 slots.
countdown_mode: "auto"
# Automatically shut down the server after this many seconds without new location checks, 0 to keep running
auto_shutdown: 0
# Compatibility handling
# 2 -> Recommended for casual/cooperative play, attempt to be compatible with everything across all versions
# 1 -> No longer in use, kept reserved in case of future use
# 0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
compatibility: 2
# log all server traffic, mostly for dev use
log_network: 0
# Options for Generation
generator:
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
enemizer_path: "EnemizerCLI/EnemizerCLI.Core"
# Folder from which the player yaml files are pulled from
player_files_path: "Players"
# amount of players, 0 to infer from player files
players: 0
# general weights file, within the stated player_files_path location
# gets used if players is higher than the amount of per-player files found to fill remaining slots
weights_file_path: "weights.yaml"
# Meta file name, within the stated player_files_path location
meta_file_path: "meta.yaml"
# Create a spoiler file
# 0 -> None
# 1 -> Spoiler without playthrough or paths to playthrough required items
# 2 -> Spoiler with playthrough (viable solution to goals)
# 3 -> Spoiler with playthrough and traversal paths towards items
spoiler: 3
# Create encrypted race roms and flag games as race mode
race: 0
# List of options that can be plando'd. Can be combined, for example "bosses, items"
# Available options: bosses, items, texts, connections
plando_options: "bosses, connections, texts"
# What to do if the current item placements appear unsolvable.
# raise -> Raise an exception and abort.
# swap -> Attempt to fix it by swapping prior placements around. (Default)
# start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
panic_method: "swap"
loglevel: "info"
logtime: false
sni_options:
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni_path: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
snes_rom_start: true
bizhawkclient_options:
# The location of the EmuHawk you want to auto launch patched ROMs with
emuhawk_path: "None"
# Set this to true to autostart a patched ROM in BizHawk with the connector script,
# to false to never open the patched rom automatically,
# or to a path to an external program to open the ROM file with that instead.
rom_start: true
adventure_options:
# File name of the standard NTSC Adventure rom.
# The licensed "The 80 Classic Games" CD-ROM contains this.
# It may also have a .a26 extension
rom_file: "roms/ADVNTURE.BIN"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program for '.a26'
# Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
rom_start: true
# Optional, additional args passed into rom_start before the .bin file
# For example, this can be used to autoload the connector script in BizHawk
# (see BizHawk --lua= option)
# Windows example:
# rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
rom_args: " "
# Set this to true to display item received messages in EmuHawk
display_msgs: true
ape_escape_3_options:
# Preferences for game session management.
# > save_state_on_room_transition: Automatically create a save state when transitioning between rooms.
# > save_state_on_item_received: Automatically create a save state when receiving a new progressive item.
# > save_state_on_location_check: Automatically create a save state when checking a new location.
# > load_state_on_connect: Load a state automatically after connecting to the multiworld if the client
# is already connected to the game and that the last save is from a save state and not a normal game save.
save_state_on_room_transition: false
save_state_on_item_received: true
save_state_on_location_check: false
load_state_on_connect: false
# Preferences for game/client-enforcement behavior
# > auto-equip : Automatically assign received gadgets to a face button
auto_equip: true
# Preferences for game generation. Only relevant for world generation and not the setup of or during play.
# > whitelist_pgc_bypass: Allow Ape Escape 3 players to enable "PGC Bypass" as a possible outcome for
# Lucky Ticket Consolation Prize.
# > whitelist_instant_goal: Allow Ape Escape 3 players to enable "Instant Goal" as a possible outcome for
# Lucky Ticket Consolation Prize.
whitelist_pgc_bypass: false
whitelist_instant_goal: false
banjo_tooie_options:
# File path of the Banjo-Tooie (USA) ROM.
rom_path: ""
# Folder path of where to save the patched ROM.
patch_path: ""
# File path of the program to automatically run.
# Leave blank to disable.
program_path: ""
# Arguments to pass to the automatically run program.
# Leave blank to disable.
# Set to "--lua=" to automatically use the correct path for the lua connector.
program_args: "--lua="
# No idea
clair_obscur_options:
{}
cv64_options:
# File name of the CV64 US 1.0 rom
rom_file: "roms/Castlevania (USA).z64"
cv_dos_options:
# File name of the Castlevania: Dawn of Sorrow ROM file.
rom_file: "roms/CASTLEVANIA1_ACVEA4_00.nds"
cvcotm_options:
# File name of the Castlevania CotM US rom
rom_file: "roms/Castlevania - Circle of the Moon (USA).gba"
cvhodis_options:
# File name of the Castlevania HoD US rom
rom_file: "roms/Castlevania - Harmony of Dissonance (USA).gba"
cvlod_options:
# File name of the CVLoD US rom
rom_file: "Castlevania - Legacy of Darkness (USA).z64"
# Settings for the DK64 randomizer.
dk64_options:
# Choose the release version of the DK64 randomizer to use.
# By setting it to master (Default) you will always pull the latest stable version.
# By setting it to dev you will pull the latest development version.
# If you want a specific version, you can set it to a AP version number eg: v1.0.45
release_branch: "master"
dkc2_options:
# File name of the Donkey Kong Country 2 US v1.1 ROM
rom_file: "roms/Donkey Kong Country 2 - Diddy's Kong Quest (USA).sfc"
# Path to the user's Donkey Kong Country 2 Poptracker Pack.
ut_poptracker_path: ""
# Folder path of the trivia database
# Preferably point it to /data/trivia/dkc2/
trivia_path: "data/trivia/dkc2"
dkc3_options:
# File name of the DKC3 US rom
rom_file: "roms/Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
earthbound_options:
# File name of the EarthBound US ROM
rom_file: "roms/EarthBound.sfc"
factorio_options:
executable: "factorio/bin/x64/factorio"
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
# server_settings: "factorio\\data\\server-settings.json"
server_settings: null
# Whether to filter item send messages displayed in-game to only those that involve you.
filter_item_sends: false
# Whether to filter connection changes displayed in-game.
filter_connection_changes: false
# Whether to send chat messages from players on the Factorio server to Archipelago.
bridge_chat_out: true
fe8_settings:
# File name of your Fire Emblem: The Sacred Stones (U) ROM
rom_file: "roms/Fire Emblem The Sacred Stones (U).gba"
ffr_options:
display_msgs: true
gauntletlegends_options:
# The location of your Retroarch folder
retroarch_path: "None"
# File name of the GL US rom
rom_file: "roms/Gauntlet Legends (U) [!].z64"
rom_start: true
glover_options:
# File path of the Glover (USA) ROM.
rom_path: ""
# Folder path of where to save the patched ROM.
patch_path: ""
# File path of the program to automatically run.
# Leave blank to disable.
program_path: ""
# Arguments to pass to the automatically run program.
# Leave blank to disable.
# Set to "--lua=" to automatically use the correct path for the lua connector.
program_args: "--lua="
gstla_options:
# File name of the GS TLA UE Rom
rom_file: "roms/Golden Sun - The Lost Age (UE) [!].gba"
hades_options:
# Path to the StyxScribe install
styx_scribe_path: "C:/Program Files/Steam/steamapps/common/Hades/StyxScribe.py"
hk_options:
# Disallows the APMapMod from showing spoiler placements.
disable_spoilers: false
jakanddaxter_options:
# Path to folder containing the ArchipelaGOAL mod executables (gk.exe and goalc.exe).
# Ensure this path contains forward slashes (/) only. This setting only applies if
# Auto Detect Root Directory is set to false.
root_directory: "%programfiles%/OpenGOAL-Launcher/features/jak1/mods/JakMods/archipelagoal"
# Attempt to find the OpenGOAL installation and the mod executables (gk.exe and goalc.exe)
# automatically. If set to true, the ArchipelaGOAL Root Directory setting is ignored.
auto_detect_root_directory: true
# Enforce friendly player options in both single and multiplayer seeds. Disabling this allows for
# more disruptive and challenging options, but may impact seed generation. Use at your own risk!
enforce_friendly_options: true
k64_options:
# File name of the K64 EN rom
rom_file: "roms/Kirby 64 - The Crystal Shards (USA).z64"
kdl3_options:
# File name of the KDL3 JP or EN rom
rom_file: "roms/Kirby's Dream Land 3.sfc"
ladx_options:
# File name of the Link's Awakening DX rom
rom_file: "roms/Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
# Set this to false to never autostart a rom (such as after patching)
# true for operating system default program
# Alternatively, a path to a program to open the .gbc file with
# Examples:
# Retroarch:
# rom_start: "C:/RetroArch-Win64/retroarch.exe -L sameboy"
# BizHawk:
# rom_start: "C:/BizHawk-2.9-win-x64/EmuHawk.exe --lua=data/lua/connector_ladx_bizhawk.lua"
rom_start: true
# Gfxmod file, get it from upstream: https://github.com/daid/LADXR/tree/master/gfx
# Only .bin or .bdiff files
# The same directory will be checked for a matching text modification file
gfx_mod_file: ""
lttp_options:
# File name of the v1.0 J rom
rom_file: "roms/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
lufia2ac_options:
# File name of the US rom
rom_file: "roms/Lufia II - Rise of the Sinistrals (USA).sfc"
messenger_settings:
game_path: "TheMessenger.exe"
metroidzeromission_options:
# File name of the Metroid: Zero Mission ROM.
rom_file: "roms/Metroid - Zero Mission (USA).gba"
# Set this to false to never autostart a rom (such as after patching),
# Set it to true to have the operating system default program open the rom
# Alternatively, set it to a path to a program to open the .gba file with
rom_start: true
mk64_options:
# File name of the MK64 ROM
rom_file: "roms/Mario Kart 64 (U) [!].z64"
metroidfusion_options:
# File name of the Metroid Fusion ROM
rom_file: "roms/Metroid Fusion (USA).gba"
rom_start: true
display_location_found_messages: true
mlss_options:
# File name of the MLSS US rom
rom_file: "roms/Mario & Luigi - Superstar Saga (U).gba"
rom_start: true
mm2_options:
# File name of the MM2 EN rom
rom_file: "roms/Mega Man 2 (USA).nes"
mmbn3_options:
# File name of the MMBN3 Blue US rom
rom_file: "roms/Mega Man Battle Network 3 - Blue Version (USA).gba"
# Set this to false to never autostart a rom (such as after patching),
# true for operating system default program
# Alternatively, a path to a program to open the .gba file with
rom_start: true
mzm_options:
rom_file: "roms/Metroid - Zero Mission (USA).gba"
rom_start: true
oot_options:
# File name of the OoT v1.0 ROM
rom_file: "roms/The Legend of Zelda - Ocarina of Time.z64"
# Set this to false to never autostart a rom (such as after patching),
# true for operating system default program
# Alternatively, a path to a program to open the .z64 file with
rom_start: true
paper_mario_settings:
# File name of the Paper Mario USA ROM
rom_file: "roms/Paper Mario (USA).z64"
# Set this to false to never autostart a rom (such as after patching),
# true for operating system default program
# Alternatively, a path to a program to open the .z64 file with
rom_start: true
papermariottyd_options:
# The location of the Dolphin you want to auto launch patched ROMs with
dolphin_path: "None"
# File name of the TTYD US iso
rom_file: "roms/Paper Mario - The Thousand-Year Door (USA).iso"
rom_start: true
pmd_eos_options:
# File name of the EoS EU rom
rom_file: "roms/POKEDUN_SORA_C2SP01_00.nds"
rom_start: true
pokemon_bw_settings:
# File name of your Pokémon Black Version ROM
black_rom: "PokemonBlack.nds"
# File name of your Pokémon White Version ROM
white_rom: "PokemonWhite.nds"
# Toggles whether Encounter Plando is enabled for players in generation.
# If disabled, yamls that use Encounter Plando do not raise OptionErrors, but display a warning.
enable_encounter_plando: true
# If enabled, files inside the rom that are changed as part of the patching process (except for base patches)
# will be dumped into a zip file next to the patched rom (for debug purposes).
dump_patched_files: false
pokemon_crystal_settings:
rom_file: "roms/Pokemon - Crystal Version (UE) [C][!].gbc"
pokemon_emerald_settings:
# File name of your English Pokemon Emerald ROM
rom_file: "roms/Pokemon - Emerald Version (USA, Europe).gba"
pokemon_frlg_settings:
# File name of your English Pokémon FireRed ROM
firered_rom_file: "roms/Pokemon - FireRed Version (USA, Europe).gba"
# File name of your English Pokémon LeafGreen ROM
leafgreen_rom_file: "roms/Pokemon - LeafGreen Version (USA, Europe).gba"
ut_poptracker_path: ""
pokemon_platinum_settings:
rom_file: "roms/pokeplatinum.nds"
pokemon_rb_options:
# File names of the Pokemon Red and Blue roms
red_rom_file: "roms/Pokemon Red (UE) [S][!].gb"
blue_rom_file: "roms/Pokemon Blue (UE) [S][!].gb"
pokepinball_settings:
# File name of the Pokemon Pinball Color US rom
rom_file: "roms/PokemonPinball.gbc"
portal2_options:
# The file path of the extras.txt file (used to generate the menu in game)
menu_file: "C:\\Program Files (x86)\\Steam\\steamapps\\sourcemods\\Portal2Archipelago\\scripts\\extras.txt"
# The port set in the portal 2 launch options e.g. 3000
default_portal2_port: 3000
saving_princess_settings:
# Path to the game executable from which files are extracted
exe_path: "Saving Princess.exe"
# Path to the mod installation folder
install_folder: "Saving Princess"
# Set this to false to never autostart the game
launch_game: true
# The console command that will be used to launch the game
# The command will be executed with the installation folder as the current directory
launch_command: "wine \"Saving Princess v0_8.exe\""
sc2_options:
# The starting width the client window in pixels
window_width: 1080
# The starting height the client window in pixels
window_height: 720
# Controls whether the game should start in windowed mode
game_windowed_mode: false
# If set to true, in-client scouting will show traps as distinct from filler
show_traps: false
# Overrides the disable forced-camera slot option. Possible values: `true`, `false`, `default`. Default uses slot value
disable_forced_camera: "default"
# Overrides the skip cutscenes slot option. Possible values: `true`, `false`, `default`. Default uses slot value
skip_cutscenes: "default"
# Overrides the slot's difficulty setting. Possible values: `casual`, `normal`, `hard`, `brutal`, `default`. Default uses slot value
game_difficulty: "default"
# Overrides the slot's gamespeed setting. Possible values: `slower`, `slow`, `normal`, `fast`, `faster`, `default`. Default uses slot value
game_speed: "default"
# Defines the colour of terran mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
terran_button_color:
- 0.0838
- 0.2898
- 0.2346
# Defines the colour of zerg mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
zerg_button_color:
- 0.345
- 0.22425
- 0.12765
# Defines the colour of protoss mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
protoss_button_color:
- 0.18975
- 0.2415
- 0.345
sf64_options:
# File path of the Star Fox 64 v1.1 ROM.
rom_path: ""
# Folder path of where to save the patched ROM.
patch_path: ""
# File path of the program to automatically run.
# Leave blank to disable.
program_path: ""
# Arguments to pass to the automatically run program.
# Leave blank to disable.
program_args: "--lua=\\\\wsl.localhost\\Ubuntu\\home\\ubufu\\ap-cm-1dd91ec\\Archipelago-main\\data\\lua\\connector_sf64_bizhawk.lua"
# Whether to enable the built in logic Tracker.
# If enabled, the 'Tracker' tab will show all unchecked locations in logic.
enable_tracker: true
sm_options:
# File name of the v1.0 J rom
rom_file: "roms/Super Metroid (JU).sfc"
sml2_options:
# File name of the Super Mario Land 2 1.0 ROM
rom_file: "roms/Super Mario Land 2 - 6 Golden Coins (USA, Europe).gb"
sms_options:
iso_file: "roms/sms_us_2002.iso"
smw_options:
# File name of the SMW US rom
rom_file: "roms/Super Mario World (USA).sfc"
soe_options:
# File name of the SoE US ROM
rom_file: "roms/Secret of Evermore (USA).sfc"
spyro2_options:
# Permits full gemsanity options for multiplayer games.
# Full gemsanity adds 2546 locations and an equal number of progression items.
# These items may be local-only or spread across the multiworld.
allow_full_gemsanity: false
stadium_options:
# File name of the Pokemon Stadium (US, 1.0) ROM
rom_file: "roms/Pokemon Stadium (US, 1.0).z64"
stardew_valley_options:
# Allow players to pick the goal 'Allsanity'. If disallowed, generation will fail.
allow_allsanity: true
# Allow players to pick the goal 'Perfection'. If disallowed, generation will fail.
allow_perfection: true
# Allow players to pick the option 'Bundle Price: Maximum'. If disallowed, it will be replaced with 'Very Expensive'
allow_max_bundles: true
# Allow players to pick the option 'Entrance Randomization: Chaos'. If disallowed, it will be replaced with 'Buildings'
allow_chaos_er: false
# Allow players to pick the option 'Shipsanity: Everything'. If disallowed, it will be replaced with 'Full Shipment With Fish'
allow_shipsanity_everything: true
# Allow players to pick the option 'Hatsanity: Near Perfection OR Post Perfection'. If disallowed, it will be replaced with 'Difficult'
allow_hatsanity_perfection: true
# Allow players to toggle on Custom logic flags. If disallowed, it will be disabled
allow_custom_logic: true
# Allow players to enable Jojapocalypse. If disallowed, it will be disabled
allow_jojapocalypse: false
tcg_card_shop_simulator_options:
# This limits goals to a reasonable number and sets all excessive settings to local_fill or Excluded for better sync experiences.
limit_checks_for_syncs: false
# Card Sanity adds pure randomness to card checks. This option disables this sanity in your multiworlds
allow_card_sanity: true
tloz_ooa_options:
# File path of the OOA US rom
rom_file: "roms/Legend of Zelda, The - Oracle of Ages (USA).gbc"
# A factor applied to the infamous heart beep sound interval.
# Valid values are: "vanilla", "half", "quarter", "disabled"
heart_beep_interval: "vanilla"
# The name of the sprite file to use (from "data/sprites/oos_ooa/").
# Putting "link" as a value uses the default game sprite.
# Putting "random" as a value randomly picks a sprite from your sprites directory for each generated ROM.
character_sprite: "link"
# The color palette used for character sprite throughout the game.
# Valid values are: "green", "red", "blue", "orange", and "random"
character_palette: "green"
# Defines if you don't want to spam the buttons to swim with the mermaid suit.
qol_mermaid_suit: true
# When enabled, playing the flute and the harp will immobilize you during a very small amount of time compared to vanilla game.
qol_quick_flute: true
# Defines if you want to skip the small dance that tokkay does
skip_tokkey_dance: false
# Defines if you want to skip the joke you tell to the sad boi
skip_boi_joke: false
tloz_oos_options:
# File name of the Oracle of Seasons US ROM
rom_file: "roms/Legend of Zelda, The - Oracle of Seasons (USA).gbc"
# File name of the Oracle of Ages US ROM (only needed for cross items)
ages_rom_file: "roms/Legend of Zelda, The - Oracle of Ages (USA).gbc"
rom_start: true
# The name of the sprite file to use (from "data/sprites/oos_ooa/").
# Putting "link" as a value uses the default game sprite.
# Putting "random" as a value randomly picks a sprite from your sprites directory for each generated ROM.
# If you want some weighted result, you can arrange the options like in your option yaml.
character_sprite: "link"
# The color palette used for character sprite throughout the game.
# Valid values are: "green", "red", "blue", "orange", and "random"
# If you want some weighted result, you can arrange the options like in your option yaml.
# If you want a color weight to only apply to a specific sprite, you can write color|sprite: weight.
# For example, red|link: 1 would add red in the possible palettes with a weight of 1 only if link is the selected sprite
character_palette: "green"
# If enabled, hidden digging spots in Subrosia are revealed as diggable tiles.
reveal_hidden_subrosia_digging_spots: true
# A factor applied to the infamous heart beep sound interval.
# Valid values are: "vanilla", "half", "quarter", "disabled"
heart_beep_interval: "vanilla"
# If true, no music will be played in the game while sound effects remain untouched
remove_music: false
tloz_options:
# File name of the Zelda 1
rom_file: "roms/Legend of Zelda, The (U) (PRG0) [!].nes"
# Set this to false to never autostart a rom (such as after patching)
# true for operating system default program
# Alternatively, a path to a program to open the .nes file with
rom_start: true
# Display message inside of Bizhawk
display_msgs: true
tloz_ph_options:
# For use with universal tracker.
# Toggles if universal tracker can use unlocked shortcuts and map warps to find shorter paths for /get_logical_path.
ut_get_logical_path_shortcuts: true
tloz_st_options:
# Train speed for each of the 4 gears, from lowest (reverse) to highest.
# defaults are -143, 0, 115, 193
train_speed:
- -143
- 0
- 115
- 193
# The train will instantly switch to the new speed when changing gears, no acceleration required.
# Does not apply to your stop gear.
train_snap_speed: true
# Allows entering stations immediately on the stop gear, no matter your speed.
train_quick_station: true
ttyd_options:
# The location of the Dolphin you want to auto launch patched ROMs with
dolphin_path: "None"
# File name of the TTYD US iso
rom_file: "roms/Paper Mario - The Thousand-Year Door (USA).iso"
rom_start: true
tunic_options:
# Disallows the TUNIC client from creating a local spoiler log.
disable_local_spoiler: false
# Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95.
limit_grass_rando: true
# Path to the user's TUNIC Poptracker Pack.
ut_poptracker_path: ""
vampire_survivors_options:
# Allow the use of unfair characters
allow_unfair_characters: false
voltorb_flip_settings:
# Allows the **experimental** choice in the **Artificial Logic** option.
allow_experimental_logic: false
wargroove_options:
# Locates the Wargroove root directory on your system.
# This is used by the Wargroove client, so it knows where to send communication files to.
root_directory: "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
# Locates the Wargroove save file directory on your system.
# This is used by the Wargroove client, so it knows where to send mod and save files to.
save_directory: "%APPDATA%"
yoshisisland_options:
# File name of the Yoshi's Island 1.0 US rom
rom_file: "roms/Super Mario World 2 - Yoshi's Island (U).sfc"
yugioh06_settings:
# File name of your Yu-Gi-Oh 2006 ROM
rom_file: "roms/YuGiOh06.gba"
zillion_options:
# File name of the Zillion US rom
rom_file: "roms/Zillion (UE) [!].sms"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
rom_start: "retroarch"

View File

@@ -208,6 +208,16 @@ Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apeb"; ValueData: "{#MyAppName}ebpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archipelago EarthBound Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
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: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; 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"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

23
kvui.py
View File

@@ -19,6 +19,7 @@ os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1" os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0" os.environ["KIVY_LOG_ENABLE"] = "0"
os.environ["SDL_MOUSE_FOCUS_CLICKTHROUGH"] = "1"
import Utils import Utils
@@ -35,6 +36,17 @@ Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0") Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for Kivy issue #9226.
# caused by kivy by default using probesysfs,
# which assumes all multi touch deviecs are touch screens.
# workaround provided by Snu of the kivy commmunity c:
from kivy.utils import platform
if platform == "linux":
options = Config.options("input")
for option in options:
if Config.get("input", option) == "probesysfs":
Config.remove_option("input", option)
# Workaround for an issue where importing kivy.core.window before loading sounds # Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded. # will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import. # kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
@@ -127,7 +139,7 @@ class ImageButton(MDIconButton):
val = kwargs.pop(kwarg, "None") val = kwargs.pop(kwarg, "None")
if val != "None": if val != "None":
image_args[kwarg.replace("image_", "")] = val image_args[kwarg.replace("image_", "")] = val
super().__init__() super().__init__(**kwargs)
self.image = ApAsyncImage(**image_args) self.image = ApAsyncImage(**image_args)
def set_center(button, center): def set_center(button, center):
@@ -143,6 +155,7 @@ class ImageButton(MDIconButton):
class ScrollBox(MDScrollView): class ScrollBox(MDScrollView):
layout: MDBoxLayout = ObjectProperty(None) layout: MDBoxLayout = ObjectProperty(None)
box_height: int = NumericProperty(dp(100))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -153,6 +166,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(ToggleButton, self).__init__(*args, **kwargs) super(ToggleButton, self).__init__(*args, **kwargs)
self.bind(state=self._update_bg) self.bind(state=self._update_bg)
self._update_bg(self, self.state)
def _update_bg(self, _, state: str): def _update_bg(self, _, state: str):
if self.disabled: if self.disabled:
@@ -170,7 +184,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
child.text_color = self.theme_cls.onPrimaryColor child.text_color = self.theme_cls.onPrimaryColor
child.icon_color = self.theme_cls.onPrimaryColor child.icon_color = self.theme_cls.onPrimaryColor
else: else:
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor self.md_bg_color = self.theme_cls.surfaceContainerLowColor
for child in self.children: for child in self.children:
if child.theme_text_color == "Primary": if child.theme_text_color == "Primary":
child.theme_text_color = "Custom" child.theme_text_color = "Custom"
@@ -184,7 +198,6 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
class ResizableTextField(MDTextField): class ResizableTextField(MDTextField):
""" """
Resizable MDTextField that manually overrides the builtin sizing. Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule. Note that in order to use this, the sizing must be specified from within a .kv rule.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -248,7 +261,7 @@ Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(MDTooltipPlain): class ToolTip(MDTooltipPlain):
pass markup = True
class ServerToolTip(ToolTip): class ServerToolTip(ToolTip):
@@ -283,6 +296,8 @@ class TooltipLabel(HovererableLabel, MDTooltip):
def on_mouse_pos(self, window, pos): def on_mouse_pos(self, window, pos):
if not self.get_root_window(): if not self.get_root_window():
return # Abort if not displayed return # Abort if not displayed
if self.disabled:
return
super().on_mouse_pos(window, pos) super().on_mouse_pos(window, pos)
if self.refs and self.hovered: if self.refs and self.hovered:

2
mypy.ini Normal file
View File

@@ -0,0 +1,2 @@
[mypy]
mypy_path = typings

View File

@@ -1,17 +1,21 @@
colorama>=0.4.6 colorama>=0.4.6
websockets>=13.0.1,<14 websockets>=13.0.1,<14
PyYAML>=6.0.2 PyYAML>=6.0.3
jellyfish>=1.1.3 jellyfish>=1.2.1
jinja2>=3.1.6 jinja2>=3.1.6
schema>=0.7.7 schema>=0.7.8
kivy>=2.3.1 kivy>=2.3.1
bsdiff4>=1.2.6 bsdiff4>=1.2.6
platformdirs>=4.3.6 platformdirs>=4.5.0
certifi>=2025.4.26 certifi>=2025.11.12
cython>=3.0.12 cython>=3.2.1
cymem>=2.0.11 cymem>=2.0.13
orjson>=3.10.15 orjson>=3.11.4
typing_extensions>=4.12.2 typing_extensions>=4.15.0
pyshortcuts>=1.9.1 pyshortcuts>=1.9.6
pathspec>=0.12.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0 kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on
Pymem>=1.13.0

0
rule_builder/__init__.py Normal file
View File

View File

@@ -0,0 +1,146 @@
from collections import defaultdict
from typing import ClassVar, cast
from typing_extensions import override
from BaseClasses import CollectionState, Item, MultiWorld, Region
from worlds.AutoWorld import LogicMixin, World
from .rules import Rule
class CachedRuleBuilderWorld(World):
"""A World subclass that provides helpers for interacting with the rule builder"""
rule_item_dependencies: dict[str, set[int]]
"""A mapping of item name to set of rule ids"""
rule_region_dependencies: dict[str, set[int]]
"""A mapping of region name to set of rule ids"""
rule_location_dependencies: dict[str, set[int]]
"""A mapping of location name to set of rule ids"""
rule_entrance_dependencies: dict[str, set[int]]
"""A mapping of entrance name to set of rule ids"""
item_mapping: ClassVar[dict[str, str]] = {}
"""A mapping of actual item name to logical item name.
Useful when there are multiple versions of a collected item but the logic only uses one. For example:
item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}"""
rule_caching_enabled: ClassVar[bool] = True
"""Flag to inform rules that the caching system for this world is enabled. It should not be overridden."""
def __init__(self, multiworld: MultiWorld, player: int) -> None:
super().__init__(multiworld, player)
self.rule_item_dependencies = defaultdict(set)
self.rule_region_dependencies = defaultdict(set)
self.rule_location_dependencies = defaultdict(set)
self.rule_entrance_dependencies = defaultdict(set)
@override
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
for item_name, rule_ids in resolved_rule.item_dependencies().items():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name, rule_ids in resolved_rule.region_dependencies().items():
self.rule_region_dependencies[region_name] |= rule_ids
for location_name, rule_ids in resolved_rule.location_dependencies().items():
self.rule_location_dependencies[location_name] |= rule_ids
for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items():
self.rule_entrance_dependencies[entrance_name] |= rule_ids
def register_rule_builder_dependencies(self) -> None:
"""Register all rules that depend on locations or entrances with their dependencies"""
for location_name, rule_ids in self.rule_location_dependencies.items():
try:
location = self.get_location(location_name)
except KeyError:
continue
if not isinstance(location.access_rule, Rule.Resolved):
continue
for item_name in location.access_rule.item_dependencies():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name in location.access_rule.region_dependencies():
self.rule_region_dependencies[region_name] |= rule_ids
for entrance_name, rule_ids in self.rule_entrance_dependencies.items():
try:
entrance = self.get_entrance(entrance_name)
except KeyError:
continue
if not isinstance(entrance.access_rule, Rule.Resolved):
continue
for item_name in entrance.access_rule.item_dependencies():
self.rule_item_dependencies[item_name] |= rule_ids
for region_name in entrance.access_rule.region_dependencies():
self.rule_region_dependencies[region_name] |= rule_ids
@override
def collect(self, state: CollectionState, item: Item) -> bool:
changed = super().collect(state, item)
if changed and self.rule_item_dependencies:
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
mapped_name = self.item_mapping.get(item.name, "")
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
for rule_id in rule_ids:
if player_results.get(rule_id, None) is False:
del player_results[rule_id]
return changed
@override
def remove(self, state: CollectionState, item: Item) -> bool:
changed = super().remove(state, item)
if not changed:
return changed
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
if self.rule_item_dependencies:
mapped_name = self.item_mapping.get(item.name, "")
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all region dependent caches as none can be trusted
if self.rule_region_dependencies:
for rule_ids in self.rule_region_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all location dependent caches as they may have lost region access
if self.rule_location_dependencies:
for rule_ids in self.rule_location_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
# clear all entrance dependent caches as they may have lost region access
if self.rule_entrance_dependencies:
for rule_ids in self.rule_entrance_dependencies.values():
for rule_id in rule_ids:
player_results.pop(rule_id, None)
return changed
@override
def reached_region(self, state: CollectionState, region: Region) -> None:
super().reached_region(state, region)
if self.rule_region_dependencies:
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
for rule_id in self.rule_region_dependencies[region.name]:
player_results.pop(rule_id, None)
class CachedRuleBuilderLogicMixin(LogicMixin):
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable]
def init_mixin(self, multiworld: "MultiWorld") -> None:
players = multiworld.get_all_ids()
self.rule_builder_cache = {player: {} for player in players}
def copy_mixin(self, new_state: "CachedRuleBuilderLogicMixin") -> "CachedRuleBuilderLogicMixin":
new_state.rule_builder_cache = {
player: player_results.copy() for player, player_results in self.rule_builder_cache.items()
}
return new_state

91
rule_builder/options.py Normal file
View File

@@ -0,0 +1,91 @@
import dataclasses
import importlib
import operator
from collections.abc import Callable, Iterable
from typing import Any, Final, Literal, Self, cast
from typing_extensions import override
from Options import CommonOptions, Option
Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains", "in"]
OPERATORS: Final[dict[Operator, Callable[..., bool]]] = {
"eq": operator.eq,
"ne": operator.ne,
"gt": operator.gt,
"lt": operator.lt,
"ge": operator.ge,
"le": operator.le,
"contains": operator.contains,
"in": operator.contains,
}
OPERATOR_STRINGS: Final[dict[Operator, str]] = {
"eq": "==",
"ne": "!=",
"gt": ">",
"lt": "<",
"ge": ">=",
"le": "<=",
}
REVERSE_OPERATORS: Final[tuple[Operator, ...]] = ("in",)
@dataclasses.dataclass(frozen=True)
class OptionFilter:
option: type[Option[Any]]
value: Any
operator: Operator = "eq"
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this option filter"""
return {
"option": f"{self.option.__module__}.{self.option.__name__}",
"value": self.value,
"operator": self.operator,
}
def check(self, options: CommonOptions) -> bool:
"""Tests the given options dataclass to see if it passes this option filter"""
option_name = next(
(name for name, cls in 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 {options.__class__.__name__}")
opt = cast(Option[Any] | None, getattr(options, option_name, None))
if opt is None:
raise ValueError(f"Invalid option: {option_name}")
fn = OPERATORS[self.operator]
return fn(self.value, opt) if self.operator in REVERSE_OPERATORS else fn(opt, self.value)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Returns a new OptionFilter instance from a dict representation"""
if "option" not in data or "value" not in data:
raise ValueError("Missing required value and/or 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")
value = data["value"]
operator = data.get("operator", "eq")
return cls(option=cast(type[Option[Any]], option), value=value, operator=operator)
@classmethod
def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple[Self, ...]:
"""Returns a tuple of OptionFilters instances from an iterable of dict representations"""
return tuple(cls.from_dict(o) for o in data)
@override
def __str__(self) -> str:
op = OPERATOR_STRINGS.get(self.operator, self.operator)
return f"{self.option.__name__} {op} {self.value}"

1822
rule_builder/rules.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,6 @@ non_apworlds: set[str] = {
"Ocarina of Time", "Ocarina of Time",
"Overcooked! 2", "Overcooked! 2",
"Raft", "Raft",
"Sudoku",
"Super Mario 64", "Super Mario 64",
"VVVVVV", "VVVVVV",
"Wargroove", "Wargroove",
@@ -394,11 +393,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
manifest = json.load(manifest_file) manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"." "does not define a \"game\"."
) )
assert manifest["game"] == worldtype.game, ( assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the" f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})." f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
) )
else: else:

View File

@@ -248,6 +248,7 @@ class WorldTestBase(unittest.TestCase):
with self.subTest("Game", game=self.game, seed=self.multiworld.seed): with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill") call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.") self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.")
placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code]
self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), self.assertLessEqual(len(self.multiworld.itempool), len(placed_items),

View File

@@ -1,9 +1,11 @@
import unittest import unittest
from typing import Callable, Dict, Optional from typing import Any, Dict, Optional
from typing_extensions import override from typing_extensions import override
from BaseClasses import CollectionState, MultiWorld, Region from BaseClasses import CollectionRule, MultiWorld, Region
from rule_builder.rules import Has, Rule
from test.general import TestWorld
class TestHelpers(unittest.TestCase): class TestHelpers(unittest.TestCase):
@@ -16,6 +18,7 @@ class TestHelpers(unittest.TestCase):
self.multiworld.game[self.player] = "helper_test_game" self.multiworld.game[self.player] = "helper_test_game"
self.multiworld.player_name = {1: "Tester"} self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed() self.multiworld.set_seed()
self.multiworld.worlds[self.player] = TestWorld(self.multiworld, self.player)
def test_region_helpers(self) -> None: def test_region_helpers(self) -> None:
"""Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior""" """Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior"""
@@ -46,8 +49,9 @@ class TestHelpers(unittest.TestCase):
"TestRegion1": {"TestRegion3"} "TestRegion1": {"TestRegion3"}
} }
exit_rules: Dict[str, Callable[[CollectionState], bool]] = { exit_rules: Dict[str, CollectionRule | Rule[Any]] = {
"TestRegion1": lambda state: state.has("test_item", self.player) "TestRegion1": lambda state: state.has("test_item", self.player),
"TestRegion2": Has("test_item2"),
} }
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions] self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
@@ -74,13 +78,17 @@ class TestHelpers(unittest.TestCase):
self.assertTrue(f"{parent} -> {exit_reg}" in created_exit_names) self.assertTrue(f"{parent} -> {exit_reg}" in created_exit_names)
if exit_reg in exit_rules: if exit_reg in exit_rules:
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}" entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
self.assertEqual(exit_rules[exit_reg], rule = exit_rules[exit_reg]
self.multiworld.get_entrance(entrance_name, self.player).access_rule) if isinstance(rule, Rule):
self.assertEqual(rule.resolve(self.multiworld.worlds[self.player]),
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
else:
self.assertEqual(rule, self.multiworld.get_entrance(entrance_name, self.player).access_rule)
for region in reg_exit_set: for region, exit_set in reg_exit_set.items():
current_region = self.multiworld.get_region(region, self.player) current_region = self.multiworld.get_region(region, self.player)
current_region.add_exits(reg_exit_set[region]) current_region.add_exits(exit_set)
exit_names = {_exit.name for _exit in current_region.exits} exit_names = {_exit.name for _exit in current_region.exits}
for reg_exit in reg_exit_set[region]: for reg_exit in exit_set:
self.assertTrue(f"{region} -> {reg_exit}" in exit_names, self.assertTrue(f"{region} -> {reg_exit}" in exit_names,
f"{region} -> {reg_exit} not in {exit_names}") f"{region} -> {reg_exit} not in {exit_names}")

View File

@@ -88,6 +88,7 @@ class TestIDs(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld) distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
datapackage = world_type.get_data_package_data() datapackage = world_type.get_data_package_data()
for item_group, item_names in datapackage["item_name_groups"].items(): for item_group, item_names in datapackage["item_name_groups"].items():
self.assertIsInstance(item_group, str, self.assertIsInstance(item_group, str,

View File

@@ -11,7 +11,7 @@ class TestImplemented(unittest.TestCase):
def test_completion_condition(self): def test_completion_condition(self):
"""Ensure a completion condition is set that has requirements.""" """Ensure a completion condition is set that has requirements."""
for game_name, world_type in AutoWorldRegister.world_types.items(): for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden and game_name not in {"Sudoku"}: if not world_type.hidden:
with self.subTest(game_name): with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
self.assertFalse(multiworld.completion_condition[1](multiworld.state)) self.assertFalse(multiworld.completion_condition[1](multiworld.state))
@@ -46,6 +46,8 @@ class TestImplemented(unittest.TestCase):
with self.subTest(game=game_name, seed=multiworld.seed): with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld) distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
call_all(multiworld, "pre_output")
for key, data in multiworld.worlds[1].fill_slot_data().items(): for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string") self.assertIsInstance(key, str, "keys in slot data must be a string")
convert_to_base_types(data) # only put base data types into slot data convert_to_base_types(data) # only put base data types into slot data
@@ -57,7 +59,7 @@ class TestImplemented(unittest.TestCase):
def test_prefill_items(self): def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill.""" """Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"): if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
with self.subTest(gamename): with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items", multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic")) "set_rules", "connect_entrances", "generate_basic"))
@@ -93,6 +95,7 @@ class TestImplemented(unittest.TestCase):
with self.subTest(game=game_name, seed=multiworld.seed): with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld) distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
# Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked # Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked
# is nondeterministic and may vary between runs with the same seed. # is nondeterministic and may vary between runs with the same seed.

View File

@@ -1,5 +1,6 @@
import unittest import unittest
from argparse import Namespace from argparse import Namespace
from collections import ChainMap
from typing import Type from typing import Type
from BaseClasses import CollectionState, MultiWorld from BaseClasses import CollectionState, MultiWorld
@@ -82,12 +83,13 @@ class TestBase(unittest.TestCase):
def test_items_in_datapackage(self): def test_items_in_datapackage(self):
"""Test that any created items in the itempool are in the datapackage""" """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.world_types.items():
with self.subTest("Game", game=game_name): with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type) multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool: for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id) self.assertIn(item.name, ChainMap(world_type.item_name_to_id, archipelago.item_name_to_id))
def test_item_links(self) -> None: def test_item_links(self) -> None:
""" """
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together. Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
@@ -121,6 +123,7 @@ class TestBase(unittest.TestCase):
call_all(multiworld, "pre_fill") call_all(multiworld, "pre_fill")
distribute_items_restrictive(multiworld) distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
call_all(multiworld, "finalize_multiworld")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}") 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.world_types.items():

View File

@@ -1,8 +1,9 @@
import unittest import unittest
from BaseClasses import PlandoOptions from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts from Options import Choice, TextChoice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@@ -15,6 +16,29 @@ class TestOptions(unittest.TestCase):
with self.subTest(game=gamename, option=option_key): with self.subTest(game=gamename, option=option_key):
self.assertTrue(option.__doc__) self.assertTrue(option.__doc__)
def test_option_defaults(self):
"""Test that defaults for submitted options are valid."""
for gamename, world_type in AutoWorldRegister.world_types.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): def test_options_are_not_set_by_world(self):
"""Test that options attribute is not already set""" """Test that options attribute is not already set"""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
@@ -44,19 +68,19 @@ class TestOptions(unittest.TestCase):
}], }],
[{ [{
"name": "ItemLinkGroup", "name": "ItemLinkGroup",
"item_pool": ["Hammer", "Bow"], "item_pool": ["Hammer", "Sword"],
"link_replacement": False, "link_replacement": False,
"replacement_item": None, "replacement_item": None,
}] }]
] ]
# we really need some sort of test world but generic doesn't have enough items for this # we really need some sort of test world but generic doesn't have enough items for this
world = AutoWorldRegister.world_types["A Link to the Past"] world = AutoWorldRegister.world_types["APQuest"]
plando_options = PlandoOptions.from_option_string("bosses") plando_options = PlandoOptions.from_option_string("bosses")
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])] item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
for link in item_links: for link in item_links:
link.verify(world, "tester", plando_options) link.verify(world, "tester", plando_options)
self.assertIn("Hammer", link.value[0]["item_pool"]) self.assertIn("Hammer", link.value[0]["item_pool"])
self.assertIn("Bow", link.value[0]["item_pool"]) self.assertIn("Sword", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items # TODO test that the group created using these options has the items
@@ -81,6 +105,19 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default)) restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup: if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default])) restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_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", "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):
self.assertFalse(any(random_key in option.valid_keys for random_key in ("random",
"random-high",
"random-low")))
for key in option.valid_keys:
self.assertFalse("random-range" in key)
def test_pickle_dumps_plando(self): def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled""" """Test that plando options using containers of a custom type can be pickled"""

View File

@@ -37,3 +37,23 @@ class TestPlayerOptions(unittest.TestCase):
self.assertEqual(new_weights["dict_2"]["option_g"], 50) self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2) self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"]) self.assertIn("option_d", new_weights["set_1"])
def test_update_dict_supports_negatives_and_zeroes(self):
original_options = {
"dict_1": {"a": 1, "b": -1},
"dict_2": {"a": 1, "b": -1},
}
new_weights = Generate.update_weights(
original_options,
{
"+dict_1": {"a": -2, "b": 2},
"-dict_2": {"a": 1, "b": 2},
},
"Tested",
"",
)
self.assertEqual(new_weights["dict_1"]["a"], -1)
self.assertEqual(new_weights["dict_1"]["b"], 1)
self.assertEqual(new_weights["dict_2"]["a"], 0)
self.assertEqual(new_weights["dict_2"]["b"], -3)
self.assertIn("a", new_weights["dict_2"])

File diff suppressed because it is too large Load Diff

View File

@@ -70,13 +70,13 @@ if __name__ == "__main__":
empty_file = str(Path(tempdir) / "empty") empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close() open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541 sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["APQuest"], ["Temp World"], ["APQuest", "Temp World"]]
p1_games: list[str] = [] p1_games: list[str] = []
data_paths: list[Path | None] = [] data_paths: list[Path | None] = []
rooms: list[str] = [] rooms: list[str] = []
multidata: Path | None multidata: Path | None
copy_world("VVVVVV", "Temp World") copy_world("APQuest", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)} offline") print(f"Generating [{n}] {', '.join(games)} offline")

View File

@@ -6,6 +6,7 @@ import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Optional, cast from typing import TYPE_CHECKING, Iterable, Optional, cast
from Utils import utcnow
from WebHostLib import to_python from WebHostLib import to_python
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -133,7 +134,7 @@ def stop_room(app_client: "FlaskClient",
room_id: str, room_id: str,
timeout: Optional[float] = None, timeout: Optional[float] = None,
simulate_idle: bool = True) -> None: simulate_idle: bool = True) -> None:
from datetime import datetime, timedelta from datetime import timedelta
from time import sleep from time import sleep
from pony.orm import db_session from pony.orm import db_session
@@ -151,10 +152,11 @@ def stop_room(app_client: "FlaskClient",
with db_session: with db_session:
room: Room = Room.get(id=room_uuid) room: Room = Room.get(id=room_uuid)
now = utcnow()
if simulate_idle: if simulate_idle:
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5) new_last_activity = now - timedelta(seconds=room.timeout + 5)
else: else:
new_last_activity = datetime.utcnow() - timedelta(days=3) new_last_activity = now - timedelta(days=3)
room.last_activity = new_last_activity room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address: if address:
@@ -188,6 +190,7 @@ def stop_room(app_client: "FlaskClient",
if address: if address:
room.timeout = original_timeout room.timeout = original_timeout
room.last_activity = new_last_activity room.last_activity = new_last_activity
room.commands.clear() # make sure there is no leftover /exit
print("timeout restored") print("timeout restored")

View File

@@ -20,7 +20,7 @@ def copy(src: str, dst: str) -> None:
src_cls = AutoWorldRegister.world_types[src] src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir() if (not src_cls.__file__.endswith(("__init__.py", "world.py")) or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()): or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}") raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name dst_folder = worlds_folder / dst_folder_name
@@ -28,11 +28,14 @@ def copy(src: str, dst: str) -> None:
raise ValueError(f"Destination {dst_folder} already exists") raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder) shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder) _new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read() for potential_world_class_file in ("__init__.py", "world.py"):
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) with open(dst_folder / potential_world_class_file, "r", encoding="utf-8-sig") as f:
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f: contents = f.read()
f.write(contents) r_src = re.escape(src)
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + r_src + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None: def delete(name: str) -> None:

View File

@@ -61,6 +61,7 @@ class TestAllGamesMultiworld(MultiworldTestBase):
with self.subTest("filling multiworld", seed=self.multiworld.seed): with self.subTest("filling multiworld", seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill") call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
@@ -78,4 +79,5 @@ class TestTwoPlayerMulti(MultiworldTestBase):
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed): with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld) distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill") call_all(self.multiworld, "post_fill")
call_all(self.multiworld, "finalize_multiworld")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

View File

@@ -25,31 +25,41 @@ class TestGenerateYamlTemplates(unittest.TestCase):
if "World: with colon" in worlds.AutoWorld.AutoWorldRegister.world_types: if "World: with colon" in worlds.AutoWorld.AutoWorldRegister.world_types:
del worlds.AutoWorld.AutoWorldRegister.world_types["World: with colon"] del worlds.AutoWorld.AutoWorldRegister.world_types["World: with colon"]
def test_name_with_colon(self) -> None: def test_name_with_colon(self) -> None:
from Options import generate_yaml_templates from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from worlds.AutoWorld import World from worlds.AutoWorld import World, WebWorld
class WebWorldWithColon(WebWorld):
options_presets = {
"Generic": {
"progression_balancing": "disabled",
"accessibility": "minimal",
}
}
class WorldWithColon(World): class WorldWithColon(World):
game = "World: with colon" game = "World: with colon"
item_name_to_id = {} item_name_to_id = {}
location_name_to_id = {} location_name_to_id = {}
web = WebWorldWithColon()
AutoWorldRegister.world_types = {WorldWithColon.game: WorldWithColon} AutoWorldRegister.world_types = {WorldWithColon.game: WorldWithColon}
with TemporaryDirectory(f"archipelago_{__name__}") as temp_dir: with TemporaryDirectory(f"archipelago_{__name__}") as temp_dir:
generate_yaml_templates(temp_dir) generate_yaml_templates(temp_dir)
path: Path path: Path
for path in Path(temp_dir).iterdir(): for path in Path(temp_dir).rglob("*"):
self.assertTrue(path.is_file()) if path.is_file():
self.assertTrue(path.suffix == ".yaml") self.assertTrue(path.suffix == ".yaml")
with path.open(encoding="utf-8") as f: with path.open(encoding="utf-8") as f:
try: try:
data = parse_yaml(f) data = parse_yaml(f)
except: except:
f.seek(0) f.seek(0)
print(f"Error in {path.name}:\n{f.read()}") print(f"Error in {path.name}:\n{f.read()}")
raise raise
self.assertIn("game", data) self.assertIn("game", data)
self.assertIn(":", data["game"]) self.assertIn(":", data["game"])
self.assertIn(data["game"], data) self.assertIn(data["game"], data)
self.assertIsInstance(data[data["game"]], dict) self.assertIsInstance(data[data["game"]], dict)

View File

@@ -2,8 +2,8 @@ description: Almost blank test yaml
name: Player{NUMBER} name: Player{NUMBER}
game: game:
Timespinner: 1 # what else APQuest: 1 # what else
requires: requires:
version: 0.2.6 version: 0.2.6
Timespinner: {} APQuest: {}

View File

@@ -1,11 +1,22 @@
import logging
import os import os
from uuid import UUID, uuid4, uuid5 from uuid import UUID, uuid4, uuid5
from flask import url_for from flask import url_for
from WebHostLib.customserver import set_up_logging, tear_down_logging
from . import TestBase from . import TestBase
def _cleanup_logger(room_id: UUID) -> None:
from Utils import user_path
tear_down_logging(room_id)
try:
os.unlink(user_path("logs", f"{room_id}.txt"))
except OSError:
pass
class TestHostFakeRoom(TestBase): class TestHostFakeRoom(TestBase):
room_id: UUID room_id: UUID
log_filename: str log_filename: str
@@ -39,7 +50,7 @@ class TestHostFakeRoom(TestBase):
try: try:
os.unlink(self.log_filename) os.unlink(self.log_filename)
except FileNotFoundError: except OSError:
pass pass
def test_display_log_missing_full(self) -> None: def test_display_log_missing_full(self) -> None:
@@ -191,3 +202,27 @@ class TestHostFakeRoom(TestBase):
with db_session: with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertNotIn("/help", (command.commandtext for command in commands)) self.assertNotIn("/help", (command.commandtext for command in commands))
def test_logger_teardown(self) -> None:
"""Verify that room loggers are removed from the global logging manager."""
from WebHostLib.customserver import tear_down_logging
room_id = uuid4()
self.addCleanup(_cleanup_logger, room_id)
set_up_logging(room_id)
self.assertIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
tear_down_logging(room_id)
self.assertNotIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
def test_handler_teardown(self) -> None:
"""Verify that handlers for room loggers are closed by tear_down_logging."""
from WebHostLib.customserver import tear_down_logging
room_id = uuid4()
self.addCleanup(_cleanup_logger, room_id)
logger = set_up_logging(room_id)
handlers = logger.handlers[:]
self.assertGreater(len(handlers), 0)
tear_down_logging(room_id)
for handler in handlers:
if isinstance(handler, logging.FileHandler):
self.assertTrue(handler.stream is None or handler.stream.closed)

View File

@@ -2,7 +2,7 @@ import unittest
from BaseClasses import PlandoOptions from BaseClasses import PlandoOptions
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet, Visibility
class TestOptionPresets(unittest.TestCase): class TestOptionPresets(unittest.TestCase):
@@ -19,6 +19,9 @@ class TestOptionPresets(unittest.TestCase):
# pass in all plando options in case a preset wants to require certain plando options # pass in all plando options in case a preset wants to require certain plando options
# for some reason # for some reason
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions))) option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
if not (Visibility.complex_ui in option.visibility or Visibility.simple_ui in option.visibility):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' is not "
f"visible in any supported UI.")
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter] supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
if not any([issubclass(option.__class__, t) for t in supported_types]): if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' " self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "

82
typings/kivy/clock.pyi Normal file
View File

@@ -0,0 +1,82 @@
from _typeshed import Incomplete
from kivy._clock import (
ClockEvent as ClockEvent,
ClockNotRunningError as ClockNotRunningError,
CyClockBase as CyClockBase,
CyClockBaseFree as CyClockBaseFree,
FreeClockEvent as FreeClockEvent,
)
__all__ = [
"Clock",
"ClockNotRunningError",
"ClockEvent",
"FreeClockEvent",
"CyClockBase",
"CyClockBaseFree",
"triggered",
"ClockBaseBehavior",
"ClockBaseInterruptBehavior",
"ClockBaseInterruptFreeBehavior",
"ClockBase",
"ClockBaseInterrupt",
"ClockBaseFreeInterruptAll",
"ClockBaseFreeInterruptOnly",
"mainthread",
]
class ClockBaseBehavior:
MIN_SLEEP: float
SLEEP_UNDERSHOOT: Incomplete
def __init__(self, async_lib: str = "asyncio", **kwargs) -> None: ...
def init_async_lib(self, lib) -> None: ...
@property
def frametime(self): ...
@property
def frames(self): ...
@property
def frames_displayed(self): ...
def usleep(self, microseconds) -> None: ...
def idle(self): ...
async def async_idle(self): ...
def tick(self) -> None: ...
async def async_tick(self) -> None: ...
def pre_idle(self) -> None: ...
def post_idle(self, ts, current): ...
def tick_draw(self) -> None: ...
def get_fps(self): ...
def get_rfps(self): ...
def get_time(self): ...
def get_boottime(self): ...
time: Incomplete
def handle_exception(self, e) -> None: ...
class ClockBaseInterruptBehavior(ClockBaseBehavior):
interupt_next_only: bool
def __init__(self, interupt_next_only: bool = False, **kwargs) -> None: ...
def init_async_lib(self, lib) -> None: ...
def usleep(self, microseconds) -> None: ...
async def async_usleep(self, microseconds) -> None: ...
def on_schedule(self, event) -> None: ...
def idle(self): ...
async def async_idle(self): ...
class ClockBaseInterruptFreeBehavior(ClockBaseInterruptBehavior):
def __init__(self, **kwargs) -> None: ...
def on_schedule(self, event): ...
class ClockBase(ClockBaseBehavior, CyClockBase):
def __init__(self, **kwargs) -> None: ...
def usleep(self, microseconds) -> None: ...
class ClockBaseInterrupt(ClockBaseInterruptBehavior, CyClockBase): ...
class ClockBaseFreeInterruptAll(ClockBaseInterruptFreeBehavior, CyClockBaseFree): ...
class ClockBaseFreeInterruptOnly(ClockBaseInterruptFreeBehavior, CyClockBaseFree):
def idle(self): ...
async def async_idle(self): ...
def mainthread(func): ...
def triggered(timeout: int = 0, interval: bool = False): ...
Clock: ClockBase

View File

@@ -1,14 +1,26 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
from bisect import bisect_right
from dataclasses import dataclass
import enum
import logging import logging
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard from typing import (TYPE_CHECKING, Any, ClassVar, Dict, Generic, Iterable,
Optional, Sequence, Tuple, TypeGuard, TypeVar, Union)
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING: if TYPE_CHECKING:
from SNIClient import SNIContext from SNIClient import SNIContext
SNES_READ_CHUNK_SIZE = 2048
"""
note: SNI v0.0.101 currently has a bug where reads from
RetroArch >2048 bytes will only return the last ~2048 bytes read.
https://github.com/alttpo/sni/issues/51
"""
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"), component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.") description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component) components.append(component)
@@ -91,3 +103,119 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None: def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
""" override this with code to handle packages from the server """ """ override this with code to handle packages from the server """
pass pass
@dataclass(frozen=True, slots=True, order=True)
class Read:
""" snes memory read - address and size in bytes """
address: int
size: int
@dataclass(frozen=True, slots=True)
class _MemRead:
location: Read
data: bytes
_T_Enum = TypeVar("_T_Enum", bound=enum.Enum)
class SnesData(Generic[_T_Enum]):
_ranges: Sequence[_MemRead]
""" sorted by address """
def __init__(self, ranges: Sequence[tuple[Read, bytes]]) -> None:
self._ranges = [_MemRead(r, d) for r, d in ranges]
def get(self, read: _T_Enum) -> bytes:
assert isinstance(read.value, Read), read.value
address = read.value.address
index = bisect_right(self._ranges, address, key=lambda r: r.location.address) - 1
assert index >= 0, (self._ranges, read.value)
mem_read = self._ranges[index]
sub_index = address - mem_read.location.address
return mem_read.data[sub_index:sub_index + read.value.size]
class SnesReader(Generic[_T_Enum]):
"""
how to use:
```
from enum import Enum
from worlds.AutoSNIClient import Read, SNIClient, SnesReader
class MyGameMemory(Enum):
game_mode = Read(WRAM_START + 0x0998, 1)
send_queue = Read(SEND_QUEUE_START, 8 * 127)
...
snes_reader = SnesReader(MyGameMemory)
snes_data = await snes_reader.read(ctx)
if snes_data is None:
snes_logger.info("error reading from snes")
return
game_mode = snes_data.get(MyGameMemory.game_mode)
```
"""
_ranges: Sequence[Read]
""" sorted by address """
def __init__(self, reads: type[_T_Enum]) -> None:
self._ranges = self._make_ranges(reads)
@staticmethod
def _make_ranges(reads: type[enum.Enum]) -> Sequence[Read]:
unprocessed_reads: list[Read] = []
for e in reads:
assert isinstance(e.value, Read), (reads.__name__, e, e.value)
unprocessed_reads.append(e.value)
unprocessed_reads.sort()
ranges: list[Read] = []
for read in unprocessed_reads:
# v end of the previous range
if len(ranges) == 0 or read.address - (ranges[-1].address + ranges[-1].size) > 255:
ranges.append(read)
else: # combine with previous range
chunk_address = ranges[-1].address
assert read.address >= chunk_address, "sort() didn't work? or something"
original_chunk_size = ranges[-1].size
new_size = max((read.address + read.size) - chunk_address,
original_chunk_size)
ranges[-1] = Read(chunk_address, new_size)
logging.debug(f"{len(ranges)=} {max(r.size for r in ranges)=}")
return ranges
async def read(self, ctx: "SNIContext") -> SnesData[_T_Enum] | None:
"""
returns `None` if reading fails,
otherwise returns the data for the registered `Enum`
"""
from SNIClient import snes_read
reads: list[tuple[Read, bytes]] = []
for r in self._ranges:
if r.size < SNES_READ_CHUNK_SIZE: # most common
response = await snes_read(ctx, r.address, r.size)
if response is None:
return None
reads.append((r, response))
else: # big read
# Problems were reported with big reads,
# so we chunk it into smaller pieces.
read_so_far = 0
collection: list[bytes] = []
while read_so_far < r.size:
remaining_size = r.size - read_so_far
chunk_size = min(SNES_READ_CHUNK_SIZE, remaining_size)
response = await snes_read(ctx, r.address + read_so_far, chunk_size)
if response is None:
return None
collection.append(response)
read_so_far += chunk_size
reads.append((r, b"".join(collection)))
return SnesData(reads)

View File

@@ -5,17 +5,18 @@ import logging
import pathlib import pathlib
import sys import sys
import time import time
from collections.abc import Callable, Iterable, Mapping
from random import Random from random import Random
from dataclasses import make_dataclass from typing import (Any, ClassVar, Dict, FrozenSet, List, Optional, Self, Set, TextIO, Tuple,
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union) TYPE_CHECKING, Type, Union)
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState from BaseClasses import CollectionState, Entrance
from rule_builder.rules import CustomRuleRegister, Rule
from Utils import Version from Utils import Version
if TYPE_CHECKING: if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance from BaseClasses import CollectionRule, Item, Location, MultiWorld, Region, Tutorial
from NetUtils import GamesPackage, MultiData from NetUtils import GamesPackage, MultiData
from settings import Group from settings import Group
@@ -47,27 +48,31 @@ class AutoWorldRegister(type):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister: def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
if "web" in dct: if "web" in dct:
assert isinstance(dct["web"], WebWorld), "WebWorld has to be instantiated." assert isinstance(dct["web"], WebWorld), "WebWorld has to be instantiated."
# filter out any events
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
# build reverse lookups
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
# build rest
dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
if "game" in dct: if "game" in dct:
assert "item_name_to_id" in dct, f"{name}: item_name_to_id is required"
assert "location_name_to_id" in dct, f"{name}: location_name_to_id is required"
# filter out any events
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
# build reverse lookups
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
# build rest
dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
assert "get_required_client_version" not in dct, f"{name}: required_client_version is an attribute now" assert "get_required_client_version" not in dct, f"{name}: required_client_version is an attribute now"
# set minimum required_client_version from bases # set minimum required_client_version from bases
if "required_client_version" in dct and bases: if "required_client_version" in dct and bases:
@@ -173,7 +178,8 @@ def _timed_call(method: Callable[..., Any], *args: Any,
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
method = getattr(multiworld.worlds[player], method_name) world = multiworld.worlds[player]
method = getattr(world, method_name)
try: try:
ret = _timed_call(method, *args, multiworld=multiworld, player=player) ret = _timed_call(method, *args, multiworld=multiworld, player=player)
except Exception as e: except Exception as e:
@@ -184,6 +190,10 @@ def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args:
logging.error(message) logging.error(message)
raise e raise e
else: else:
# Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called
# Can be removed once dependency system is improved
if method_name == "set_rules" and hasattr(world, "register_rule_builder_dependencies"):
call_single(multiworld, "register_rule_builder_dependencies", player)
return ret return ret
@@ -224,7 +234,7 @@ class WebWorld(metaclass=WebWorldRegister):
tutorials: List["Tutorial"] tutorials: List["Tutorial"]
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide.""" """docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide."""
theme = "grass" theme: str = "grass"
"""Choose a theme for you /game/* pages. """Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone""" Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
@@ -353,7 +363,7 @@ class World(metaclass=AutoWorldRegister):
def __getattr__(self, item: str) -> Any: def __getattr__(self, item: str) -> Any:
if item == "settings": if item == "settings":
return self.__class__.settings return getattr(self.__class__, item)
raise AttributeError raise AttributeError
# overridable methods that get called by Main.py, sorted by execution order # overridable methods that get called by Main.py, sorted by execution order
@@ -420,6 +430,23 @@ class World(metaclass=AutoWorldRegister):
This happens before progression balancing, so the items may not be in their final locations yet. This happens before progression balancing, so the items may not be in their final locations yet.
""" """
def finalize_multiworld(self) -> None:
"""
Optional Method that is called after fill and progression balancing.
This is the last stage of generation where worlds may change logically relevant data,
such as item placements and connections. To not break assumptions,
only ever increase accessibility, never decrease it.
"""
pass
def pre_output(self):
"""
Optional method that is called before output generation.
Items and connections are not meant to be moved anymore,
anything that would affect logical spheres is forbidden at this point.
"""
pass
def generate_output(self, output_directory: str) -> None: def generate_output(self, output_directory: str) -> None:
""" """
This method gets called from a threadpool, do not use multiworld.random here. This method gets called from a threadpool, do not use multiworld.random here.
@@ -484,7 +511,14 @@ class World(metaclass=AutoWorldRegister):
raise NotImplementedError raise NotImplementedError
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count.""" """
Called when the item pool needs to be filled with additional items to match location count.
Any returned item name must be for a "repeatable" item, i.e. one that it's okay to generate arbitrarily many of.
For most worlds this will be one or more of your filler items, but the classification of these items
does not need to be ItemClassification.filler.
The item name returned can be for a trap, useful, and/or progression item as long as it's repeatable.
"""
logging.warning(f"World {self} is generating a filler item without custom filler pool.") logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.random.choice(tuple(self.item_name_to_id.keys())) return self.random.choice(tuple(self.item_name_to_id.keys()))
@@ -538,6 +572,10 @@ class World(metaclass=AutoWorldRegister):
return True return True
return False return False
def reached_region(self, state: "CollectionState", region: "Region") -> None:
"""Called when a region is newly reachable by the state."""
pass
# following methods should not need to be overridden. # following methods should not need to be overridden.
def create_filler(self) -> "Item": def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name()) return self.create_item(self.get_filler_item_name())
@@ -586,6 +624,64 @@ class World(metaclass=AutoWorldRegister):
res["checksum"] = data_package_checksum(res) res["checksum"] = data_package_checksum(res)
return res return res
@classmethod
def get_rule_cls(cls, name: str) -> type[Rule[Self]]:
"""Returns the world-registered or default rule with the given name"""
return CustomRuleRegister.get_rule_cls(cls.game, name)
@classmethod
def rule_from_dict(cls, data: Mapping[str, Any]) -> Rule[Self]:
"""Create a rule instance from a serialized dict representation"""
name = data.get("rule", "")
rule_class = cls.get_rule_cls(name)
return rule_class.from_dict(data, cls)
def set_rule(self, spot: Location | Entrance, rule: CollectionRule | Rule[Any]) -> None:
"""Sets an access rule for a location or entrance"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
if isinstance(spot, Entrance):
self._register_rule_indirects(rule, spot)
spot.access_rule = rule
def set_completion_rule(self, rule: CollectionRule | Rule[Any]) -> None:
"""Set the completion rule for this world"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
self.multiworld.completion_condition[self.player] = rule
def create_entrance(
self,
from_region: Region,
to_region: Region,
rule: CollectionRule | Rule[Any] | None = None,
name: str | None = None,
force_creation: bool = False,
) -> Entrance | None:
"""Try to create an entrance between regions with the given rule,
skipping it if the rule resolves to False (unless force_creation is True)"""
if rule is not None and isinstance(rule, Rule):
rule = rule.resolve(self)
if rule.always_false and not force_creation:
return None
self.register_rule_dependencies(rule)
entrance = from_region.connect(to_region, name, rule=rule)
if rule and isinstance(rule, Rule.Resolved):
self._register_rule_indirects(rule, entrance)
return entrance
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
"""Hook for registering dependencies when a rule is assigned for this world"""
pass
def _register_rule_indirects(self, resolved_rule: Rule.Resolved, entrance: Entrance) -> None:
if self.explicit_indirect_conditions:
for indirect_region in resolved_rule.region_dependencies().keys():
self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance)
# any methods attached to this can be used as part of CollectionState, # any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together # please use a prefix as all of them get clobbered together

View File

@@ -21,6 +21,10 @@ if TYPE_CHECKING:
from Utils import Version from Utils import Version
class ImproperlyConfiguredAutoPatchError(Exception):
pass
class AutoPatchRegister(abc.ABCMeta): class AutoPatchRegister(abc.ABCMeta):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {} patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
file_endings: ClassVar[Dict[str, AutoPatchRegister]] = {} file_endings: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -30,8 +34,28 @@ class AutoPatchRegister(abc.ABCMeta):
new_class = super().__new__(mcs, name, bases, dct) new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct: if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}") if not callable(getattr(new_class, "patch", None)):
raise ImproperlyConfiguredAutoPatchError(
f"Container {new_class} uses metaclass AutoPatchRegister, but does not have a patch method defined."
)
patch_file_ending = dct.get("patch_file_ending")
if patch_file_ending == ".zip":
raise ImproperlyConfiguredAutoPatchError(
f'Auto patch container {new_class} uses file ending ".zip", which is not allowed.'
)
if patch_file_ending is None:
raise ImproperlyConfiguredAutoPatchError(
f"Need an expected file ending for auto patch container {new_class}"
)
existing_handler = AutoPatchRegister.file_endings.get(patch_file_ending)
if existing_handler:
raise ImproperlyConfiguredAutoPatchError(
f"Two auto patch containers are using the same file extension: {new_class}, {existing_handler}"
)
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class return new_class

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
class Type(Enum): class Type(Enum):
@@ -204,6 +204,18 @@ def install_apworld(apworld_path: str = "") -> None:
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.") Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
def export_datapackage() -> None:
import json
from worlds import network_data_package
path = user_path("datapackage_export.json")
with open(path, "w") as f:
json.dump(network_data_package, f, indent=4)
open_file(path)
components: List[Component] = [ components: List[Component] = [
# Launcher # Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN), Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
@@ -213,6 +225,8 @@ components: List[Component] = [
description="Host a generated multiworld on your computer."), description="Host a generated multiworld on your computer."),
Component('Generate', 'Generate', cli=True, Component('Generate', 'Generate', cli=True,
description="Generate a multiworld with the YAMLs in the players folder."), description="Generate a multiworld with the YAMLs in the players folder."),
Component("Options Creator", "OptionsCreator", "ArchipelagoOptionsCreator", component_type=Type.TOOL,
description="Visual creator for Archipelago option files."),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"), Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."), description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
@@ -230,8 +244,11 @@ components: List[Component] = [
Component('Zillion Client', 'ZillionClient', Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')), file_identifier=SuffixIdentifier('.apzl')),
#MegaMan Battle Network 3 # MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')) Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')),
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL,
description="Write item/location data for installed worlds to a file and open it."),
] ]
@@ -262,6 +279,10 @@ if not is_frozen():
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items() games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
if not worldtype.zip_path] if not worldtype.zip_path]
global_apignores = read_apignore(local_path("data", "GLOBAL.apignore"))
if not global_apignores:
raise RuntimeError("Could not read global apignore file for build component")
apworlds_folder = os.path.join("build", "apworlds") apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True) os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in games: for worldname, worldtype in games:
@@ -275,11 +296,11 @@ if not is_frozen():
manifest = json.load(manifest_file) manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"." "does not define a \"game\"."
) )
assert manifest["game"] == worldtype.game, ( assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the" f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})." f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
) )
else: else:
@@ -289,18 +310,17 @@ if not is_frozen():
apworld = APWorldContainer(str(zip_path)) apworld = APWorldContainer(str(zip_path))
apworld.game = worldtype.game apworld.game = worldtype.game
manifest.update(apworld.get_manifest()) manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json" apworld.manifest_path = os.path.join(file_name, "archipelago.json")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: local_ignores = read_apignore(pathlib.Path(world_directory, ".apignore"))
for path in pathlib.Path(world_directory).rglob("*"): apignores = global_apignores + local_ignores if local_ignores else global_apignores
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path: with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
continue for file in apignores.match_tree_files(world_directory, negate=True):
if not relative_path.endswith("archipelago.json"): zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest)) zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder) open_folder(apworlds_folder)
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders.")) description="Build APWorlds from loose-file world folders."))

View File

@@ -0,0 +1,429 @@
import logging
from typing import TYPE_CHECKING
from .Items import pokemon_stadium_items, gym_badge_codes, box_upgrade_items, cup_tier_upgrade_items
from .Locations import pokemon_stadium_locations, event_locations
from NetUtils import ClientStatus
from .Types import LocData
import Utils
import worlds._bizhawk as bizhawk
from worlds._bizhawk.client import BizHawkClient
logger = logging.getLogger('Client')
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
class PokemonStadiumClient(BizHawkClient):
game = 'Pokemon Stadium'
system = 'N64'
patch_suffix = '.apstadium'
def __init__(self):
super().__init__()
self.local_checked_locations = set()
self.glc_loaded = False
self.cups_loaded = False
self.minigame_index = None
self.minigame_done = False
self.minigame_check_sent = False
async def validate_rom(self, ctx: 'BizHawkClientContext') -> bool:
try:
# Check ROM name
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(0x20, 15, 'ROM')]))[0]).decode('ascii')
if rom_name != 'POKEMON STADIUM':
logger.info('Invalid ROM for Pokemon Stadium AP World')
return False
except bizhawk.RequestFailedError:
return False
ctx.game = self.game
ctx.items_handling = 0b111
ctx.want_slot_data = True
return True
async def game_watcher(self, ctx: 'BizHawkClientContext') -> None:
item_codes = {net_item.item for net_item in ctx.items_received}
flags = await bizhawk.read(ctx.bizhawk_ctx, [
(0x420000, 4, 'RDRAM'), # GLC Flag
(0x420010, 4, 'RDRAM'), # Entered Battle Flag
(0x148AC8, 12, 'RDRAM'), # Beat Rival Flag
(0x12FC1C, 4, 'RDRAM'), # Minigame being played
(0x124860, 4, 'RDRAM'), # Minigame results
(0xAE77F, 1, 'RDRAM'), # Enemy team HP slot 1
(0xAE7D3, 1, 'RDRAM'), # Enemy team HP slot 2
(0xAE827, 1, 'RDRAM'), # Enemy team HP slot 3
(0x220C19, 3, 'RDRAM'), # GLC Rentals address
(0x221D99, 3, 'RDRAM'), # GLC Registration table address
(0x218CE9, 3, 'RDRAM'), # Poke Cup Rentals address
(0x219E69, 3, 'RDRAM'), # Poke Cup Registration table address
(0x218CB9, 3, 'RDRAM'), # Prime Cup Rentals address
(0x219E39, 3, 'RDRAM'), # Prime Cup Registration table address
(0x218C99, 3, 'RDRAM'), # Petit Cup Rentals address
(0x219E19, 3, 'RDRAM'), # Petit Cup Registration table address
(0x218CA9, 3, 'RDRAM'), # Pika Cup Rentals address
(0x219E29, 3, 'RDRAM'), # Pika Cup Registration table address
(0x420020, 4, 'RDRAM'), # Picking a Cup tier
]
)
player_has_battled = flags[1] != b'\x00\x00\x00\x00'
battle_info = await bizhawk.read(ctx.bizhawk_ctx, [(0x0AE540, 4, 'RDRAM')])
mode = int(battle_info[0].hex()[:2])
gym_info = battle_info[0].hex()[4:]
gym_number = int(battle_info[0].hex()[4:6])
trainer_index = int(battle_info[0].hex()[6:])
if player_has_battled:
player_won = all(x == b'\x00' for x in flags[5:8])
if player_won:
ap_code = 20000000 + (mode * 100) + (gym_number * 10) + trainer_index
# If a Gym Leader was beaten or the last trainer for a Cup was beaten an additional check must be sent
if mode == 7 and trainer_index == 4:
locations_to_check = set([ap_code, ap_code + 1])
elif trainer_index == 8:
locations_to_check = set([ap_code, ap_code - trainer_index, ap_code + 1])
else:
locations_to_check = set([ap_code])
try:
await ctx.check_locations(locations_to_check)
await bizhawk.write(ctx.bizhawk_ctx, [(0x420010, [0x00, 0x00, 0x00, 0x00], 'RDRAM')])
self.glc_loaded = False
except:
pass
glc_flag = int.from_bytes(flags[0], byteorder='big')
if glc_flag == 2 and not self.glc_loaded:
self.glc_loaded = True
self.GLC_UNLOCK_FLAGS = [
0x147B70, # Pewter
0x147B98, # Cerulean
0x147BC0, # Vermilion
0x147BE8, # Celadon
0x147C10, # Fuchsia
0x147C38, # Saffron
0x147C60, # Cinnabar
0x147C88, # Viridian
0x147CB1, # E4 entrance
0x147CD9, # E4 exit
0x147D01, # E4
]
# UUDDLLRR
self.GLC_CURSOR_TARGETS = [
0x147B84, # Brock, 00000002
0x147BAC, # Misty, 03000103
0x147BD4, # Surge, 04020200
0x147BFC, # Erika, 05030500
0x147C24, # Koga, 06040604
0x147C4C, # Sabrina, 07050007
0x147C74, # Blaine, 00080608
0x147C9C, # Giovanni, 07000709
]
gym_codes = [
pokemon_stadium_items['Pewter City Key'].ap_code,
pokemon_stadium_items['Cerulean City Key'].ap_code,
pokemon_stadium_items['Vermillion City Key'].ap_code,
pokemon_stadium_items['Celadon City Key'].ap_code,
pokemon_stadium_items['Fuchsia City Key'].ap_code,
pokemon_stadium_items['Saffron City Key'].ap_code,
pokemon_stadium_items['Cinnabar Island Key'].ap_code,
pokemon_stadium_items['Viridian City Key'].ap_code,
]
self.unlocked_gyms = [i + 1 for i, code in enumerate(gym_codes) if code in item_codes]
victory_road_open = set(gym_badge_codes).issubset(item_codes)
if victory_road_open:
self.unlocked_gyms.append(9)
if gym_codes[0] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[0], [0x00, 0x01], 'RDRAM')])
await self.update_brock_cursor(ctx)
if gym_codes[1] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[1], [0x00, 0x01], 'RDRAM')])
await self.update_misty_cursor(ctx)
if gym_codes[2] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[2], [0x00, 0x01], 'RDRAM')])
await self.update_surge_cursor(ctx)
if gym_codes[3] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[3], [0x00, 0x01], 'RDRAM')])
await self.update_erika_cursor(ctx)
if gym_codes[4] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[4], [0x00, 0x01], 'RDRAM')])
await self.update_koga_cursor(ctx)
if gym_codes[5] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[5], [0x00, 0x01], 'RDRAM')])
await self.update_sabrina_cursor(ctx)
if gym_codes[6] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[6], [0x00, 0x01], 'RDRAM')])
await self.update_blaine_cursor(ctx)
if gym_codes[7] in item_codes:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[7], [0x00, 0x01], 'RDRAM')])
await self.update_giovanni_cursor(ctx, item_codes)
if victory_road_open:
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[8], [0x01], 'RDRAM')])
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[9], [0x01], 'RDRAM')])
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[10], [0x01], 'RDRAM')])
if len(self.unlocked_gyms) > 0 and gym_info != '0804':
first_gym = self.unlocked_gyms[0] - 1
await bizhawk.write(ctx.bizhawk_ctx, [(0x147D50, [0x00, first_gym], 'RDRAM')])
await bizhawk.write(ctx.bizhawk_ctx, [(0x146F38, [0x52, 0x61, 0xFF, 0x82], 'RDRAM')])
elif glc_flag != 2 and self.glc_loaded:
self.glc_loaded = False
text = flags[2].decode("ascii", errors="ignore")
if text == 'Magnificent!':
await ctx.check_locations(set([event_locations['Beat Rival'].ap_code]))
await bizhawk.write(ctx.bizhawk_ctx, [(0x420010, [0x00, 0x00, 0x00, 0x00], 'RDRAM')])
cups_flag = int.from_bytes(flags[18], byteorder='big')
if cups_flag != 0 and not self.cups_loaded:
self.cups_loaded = True
if mode == 3:
cup_tier_item = cup_tier_upgrade_items['Poké Cup - Tier Upgrade'].ap_code
else:
cup_tier_item = cup_tier_upgrade_items['Prime Cup - Tier Upgrade'].ap_code
cup_tier = sum(1 for net_item in ctx.items_received if net_item.item == cup_tier_item)
await bizhawk.write(ctx.bizhawk_ctx, [(0x147018, [0x00, 0x00, 0x00, cup_tier], 'RDRAM')])
elif cups_flag == 0:
self.cups_loaded = False
# GLC Boxes
selecting_team = flags[8] == b'\x22\x0E\x20'
registering_team = flags[9] == b'\x22\x1F\xA0'
if selecting_team or registering_team:
address = 0x220E23 if selecting_team else 0x221FA3
item = box_upgrade_items['GLC PC Box Upgrade'].ap_code
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
table_size = 29 + 20 * box_count
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
# Poke Boxes
selecting_team = flags[10] == b'\x21\x8F\x10'
registering_team = flags[11] == b'\x21\xA0\x90'
if selecting_team or registering_team:
address = 0x218F13 if selecting_team else 0x21A093
item = box_upgrade_items['Poke Cup PC Box Upgrade'].ap_code
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
table_size = 29 + 20 * box_count
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
# Prime Boxes
selecting_team = flags[12] == b'\x21\x8F\x10'
registering_team = flags[13] == b'\x21\xA0\x90'
if selecting_team or registering_team:
address = 0x218F13 if selecting_team else 0x21A093
item = box_upgrade_items['Prime Cup PC Box Upgrade'].ap_code
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
table_size = 29 + 20 * box_count
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
# Minigames
if flags[3].startswith(b'\x00\x03\x00') and flags[3][3] in range(9):
self.minigame_index = flags[3][3]
if self.minigame_index != None and flags[4] == b'\x00\x00\x00\x00':
self.minigame_done = False
if self.minigame_index != None and not self.minigame_done and flags[4] == b'\x01\x00\x00\x00':
self.minigame_done = True
self.minigame_check_sent = False
if self.minigame_done and self.minigame_index != None and not self.minigame_check_sent:
minigame_ap_acode = 20000100 + self.minigame_index
await ctx.check_locations([minigame_ap_acode])
self.minigame_check_sent = True
# Send game clear
if not ctx.finished_game and pokemon_stadium_items['Victory'].ap_code in item_codes:
ctx.finished_game = True
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL,
}])
def lowest_unlocked_from(self, lower_bound):
for i in range(lower_bound, 9):
if i in self.unlocked_gyms:
return i
return 0
def highest_unlocked_from(self, upper_bound):
for i in range(upper_bound, 0, -1):
if i in self.unlocked_gyms:
return i
return 0
async def update_brock_cursor(self, ctx):
# Determine UP: lowest unlocked gym from 4 to 9
up = self.lowest_unlocked_from(4)
# Determine RIGHT: lowest of 2 or 3 or 4 if any are unlocked
right = 0
misty_unlocked = 2 in self.unlocked_gyms
surge_unlocked = 3 in self.unlocked_gyms
erika_unlocked = 4 in self.unlocked_gyms
if misty_unlocked:
right = 2
elif surge_unlocked:
right = 3
elif erika_unlocked:
right = 4
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[0], [up, 0x00, 0x00, right], 'RDRAM')])
async def update_misty_cursor(self, ctx):
# Determine UP: lowest unlocked gym from 4 to 9
up = self.lowest_unlocked_from(4)
# Determine LEFT: is Brock unlocked
left = 1 if 1 in self.unlocked_gyms else 0
# Determine RIGHT: is Surge unlocked
right = 3 if 3 in self.unlocked_gyms else 0
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[1], [up, 0x00, left, right], 'RDRAM')])
async def update_surge_cursor(self, ctx):
# Determine UP: lowest unlocked gym from 4 to 9
up = self.lowest_unlocked_from(4)
# Determine DOWN: is Misty unlocked
down = 2 if 2 in self.unlocked_gyms else 0
# Determine LEFT: is Misty or Brock unlocked
left = 0
misty_unlocked = 2 if 2 in self.unlocked_gyms else 0
brock_unlocked = 1 if 1 in self.unlocked_gyms else 0
if misty_unlocked:
left = 2
elif brock_unlocked:
left = 1
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[2], [up, down, left, 0x00], 'RDRAM')])
async def update_erika_cursor(self, ctx):
# Determine UP: lowest unlocked gym from 5 to 9
up = self.lowest_unlocked_from(5)
# Determine DOWN: highest unlocked gym from 3 to 1
down = self.highest_unlocked_from(3)
# Determine LEFT: is Koga or Sabrina unlocked
left = 0
koga_unlocked = 5 if 5 in self.unlocked_gyms else 0
sabrina_unlocked = 6 if 6 in self.unlocked_gyms else 0
if koga_unlocked:
left = 5
elif sabrina_unlocked:
left = 6
# Determine RIGHT: is Surge unlocked
right = 3 if 3 in self.unlocked_gyms else 0
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[3], [up, down, left, right], 'RDRAM')])
async def update_koga_cursor(self, ctx):
# Determine UP: lowest unlocked gym from 6 to 9
up = self.lowest_unlocked_from(6)
# Determine DOWN: highest unlocked gym from 2 to 1
down = self.highest_unlocked_from(2)
# Determine LEFT: is Sabrina unlocked
left = 6 if 6 in self.unlocked_gyms else 0
# Determine RIGHT: is Erika or Surge unlocked
right = 0
erika_unlocked = 4 if 4 in self.unlocked_gyms else 0
surge_unlocked = 3 if 3 in self.unlocked_gyms else 0
if erika_unlocked:
right = 4
elif surge_unlocked:
right = 3
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[4], [up, down, left, right], 'RDRAM')])
async def update_sabrina_cursor(self, ctx):
# Determine DOWN: highest unlocked gym from 5 to 1
down = self.highest_unlocked_from(5)
# Determine RIGHT: is Blaine or Giovanni unlocked
right = 0
blaine_unlocked = 7 if 7 in self.unlocked_gyms else 0
giovanni_unlocked = 8 if 8 in self.unlocked_gyms else 0
if blaine_unlocked:
right = 7
elif giovanni_unlocked:
right = 8
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[5], [0x00, down, 0x00, right], 'RDRAM')])
async def update_blaine_cursor(self, ctx):
# Determine DOWN: highest unlocked gym from 5 to 1
down = self.highest_unlocked_from(5)
# Determine LEFT: is Sabrina unlocked
left = 6 if 6 in self.unlocked_gyms else 0
# Determine RIGHT: is Giovanni unlocked or do you have all badges needed
if 8 in self.unlocked_gyms:
right = 8
elif 9 in self.unlocked_gyms:
right = 9
else:
right = 0
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[6], [0x00, down, left, right], 'RDRAM')])
async def update_giovanni_cursor(self, ctx, item_codes):
# Determine UP: All badges obtained?
up = 9 if set(gym_badge_codes).issubset(item_codes) else 0
# Determine DOWN: highest unlocked gym from 5 to 1
down = self.highest_unlocked_from(5)
# Determine LEFT: is Blaine or Sabrina unlocked
left = 0
blaine_unlocked = 7 if 7 in self.unlocked_gyms else 0
sabrina_unlocked = 6 if 6 in self.unlocked_gyms else 0
if blaine_unlocked:
left = 7
elif sabrina_unlocked:
left = 6
# Determine RIGHT: All badges obtained?
right = up
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[7], [up, down, left, right], 'RDRAM')])

View File

@@ -0,0 +1,132 @@
import logging
import random
from BaseClasses import Item, ItemClassification
from .Types import ItemData, PokemonStadiumItem
from .Locations import get_total_locations
from typing import List, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from . import PokemonStadiumWorld
def create_itempool(world: 'PokemonStadiumWorld') -> List[Item]:
item_pool: List[Item] = []
# This is a good place to grab anything you need from options
for name in pokemon_stadium_items:
if name != 'Victory' and name not in world.starting_gym_keys:
item_pool.append(create_item(world, name))
victory = create_item(world, 'Victory')
world.multiworld.get_location('Beat Rival', world.player).place_locked_item(victory)
item_pool += create_multiple_items(world, 'Poké Cup - Tier Upgrade', 3, ItemClassification.progression)
item_pool += create_multiple_items(world, 'Prime Cup - Tier Upgrade', 3, ItemClassification.progression)
item_pool += create_multiple_items(world, 'GLC PC Box Upgrade', 6, ItemClassification.useful)
item_pool += create_multiple_items(world, 'Poke Cup PC Box Upgrade', 6, ItemClassification.useful)
item_pool += create_multiple_items(world, 'Prime Cup PC Box Upgrade', 6, ItemClassification.useful)
item_pool += create_junk_items(world, get_total_locations(world) - len(item_pool) - 1)
return item_pool
def create_item(world: 'PokemonStadiumWorld', name: str) -> Item:
data = item_table[name]
return PokemonStadiumItem(name, data.classification, data.ap_code, world.player)
def create_multiple_items(world: "PokemonStadiumWorld", name: str, count: int, item_type: ItemClassification = ItemClassification.progression) -> List[Item]:
data = item_table[name]
itemlist: List[Item] = []
for _ in range(count):
itemlist += [PokemonStadiumItem(name, item_type, data.ap_code, world.player)]
return itemlist
def create_junk_items(world: 'PokemonStadiumWorld', count: int) -> List[Item]:
junk_pool: List[Item] = []
junk_list: Dict[str, int] = {}
for name in item_table.keys():
ic = item_table[name].classification
if ic == ItemClassification.filler:
junk_list[name] = junk_weights.get(name)
for _ in range(count):
junk_pool.append(world.create_item(world.random.choices(list(junk_list.keys()), weights=list(junk_list.values()), k=1)[0]))
return junk_pool
pokemon_stadium_items = {
# Progression items
'Pewter City Key': ItemData(10000001, ItemClassification.progression),
'Boulder Badge': ItemData(10000002, ItemClassification.progression),
'Cerulean City Key': ItemData(10000003, ItemClassification.progression),
'Cascade Badge': ItemData(10000004, ItemClassification.progression),
'Vermillion City Key': ItemData(10000005, ItemClassification.progression),
'Thunder Badge': ItemData(10000006, ItemClassification.progression),
'Celadon City Key': ItemData(10000007, ItemClassification.progression),
'Rainbow Badge': ItemData(10000008, ItemClassification.progression),
'Fuchsia City Key': ItemData(10000009, ItemClassification.progression),
'Soul Badge': ItemData(10000010, ItemClassification.progression),
'Saffron City Key': ItemData(10000011, ItemClassification.progression),
'Marsh Badge': ItemData(10000012, ItemClassification.progression),
'Cinnabar Island Key': ItemData(10000013, ItemClassification.progression),
'Volcano Badge': ItemData(10000014, ItemClassification.progression),
'Viridian City Key': ItemData(10000015, ItemClassification.progression),
'Earth Badge': ItemData(10000016, ItemClassification.progression),
# Victory is added here since in this organization it needs to be in the default item pool
'Victory': ItemData(10000000, ItemClassification.progression)
}
gym_keys = [
'Pewter City Key',
'Cerulean City Key',
'Vermillion City Key',
'Celadon City Key',
'Fuchsia City Key',
'Saffron City Key',
'Cinnabar Island Key',
'Viridian City Key',
]
gym_badge_codes = [
10000002,
10000004,
10000006,
10000008,
10000010,
10000012,
10000014,
10000016,
]
cup_tier_upgrade_items = {
'Poké Cup - Tier Upgrade': ItemData(10000017, ItemClassification.progression),
'Prime Cup - Tier Upgrade': ItemData(10000018, ItemClassification.progression),
}
box_upgrade_items = {
'GLC PC Box Upgrade': ItemData(10000101, ItemClassification.useful),
'Poke Cup PC Box Upgrade' : ItemData(10000102, ItemClassification.useful),
'Prime Cup PC Box Upgrade' : ItemData(10000103, ItemClassification.useful),
}
junk_items = {
"Pokedoll": ItemData(10000200, ItemClassification.filler, 0),
}
junk_weights = {
"Pokedoll": 40,
}
item_table = {
**pokemon_stadium_items,
**cup_tier_upgrade_items,
**box_upgrade_items,
**junk_items,
}

View File

@@ -0,0 +1,193 @@
from typing import Dict, TYPE_CHECKING
import logging
from .Types import LocData
if TYPE_CHECKING:
from . import PokemonStadiumWorld
def get_total_locations(world: 'PokemonStadiumWorld') -> int:
if world.options.Trainersanity.value == 1:
location_table.update(trainersanity_locations)
return len(location_table)
def get_location_names() -> Dict[str, int]:
temp_loc_table = location_table.copy()
temp_loc_table.update(trainersanity_locations)
names = {name: data.ap_code for name, data in temp_loc_table.items()}
return names
def is_valid_location(world: 'PokemonStadiumWorld', name) -> bool:
return True
pokemon_stadium_locations = {
'Magikarp\'s Splash': LocData(20000100, 'Kids Club'),
'Clefairy Says': LocData(20000101, 'Kids Club'),
'Run, Rattata, Run': LocData(20000102, 'Kids Club'),
'Snore War': LocData(20000103, 'Kids Club'),
'Thundering Dynamo': LocData(20000104, 'Kids Club'),
'Sushi-Go-Round': LocData(20000105, 'Kids Club'),
'Ekans\'s Hoop Hurl': LocData(20000106, 'Kids Club'),
'Rock Harden': LocData(20000107, 'Kids Club'),
'Dig! Dig! Dig!': LocData(20000108, 'Kids Club'),
'Poké Cup - Poké Ball - Prize': LocData(20000300, 'Poké Cup'),
'Poké Cup - Poké Ball - Tier Upgrade': LocData(20000309, 'Poké Cup'),
'Poké Cup - Great Ball - Prize': LocData(20000310, 'Poké Cup'),
'Poké Cup - Great Ball - Tier Upgrade': LocData(20000319, 'Poké Cup'),
'Poké Cup - Ultra Ball - Prize': LocData(20000320, 'Poké Cup'),
'Poké Cup - Ultra Ball - Tier Upgrade': LocData(20000329, 'Poké Cup'),
'Poké Cup - Master Ball - Prize': LocData(20000330, 'Poké Cup'),
'Petit Cup Prize': LocData(20000400, 'Petit Cup'),
'Pika Cup Prize': LocData(20000500, 'Pika Cup'),
'Prime Cup - Poké Ball - Prize': LocData(20000600, 'Prime Cup'),
'Prime Cup - Poké Ball - Tier Upgrade': LocData(20000609, 'Prime Cup'),
'Prime Cup - Great Ball - Prize': LocData(20000610, 'Prime Cup'),
'Prime Cup - Great Ball - Tier Upgrade': LocData(20000619, 'Prime Cup'),
'Prime Cup - Ultra Ball - Prize': LocData(20000620, 'Prime Cup'),
'Prime Cup - Ultra Ball - Tier Upgrade': LocData(20000629, 'Prime Cup'),
'Prime Cup - Master Ball - Prize': LocData(20000630, 'Prime Cup'),
'BROCK': LocData(20000704, 'Gym Leader Castle'),
'Pewter Gym': LocData(20000705, 'Gym Leader Castle'),
'MISTY': LocData(20000714, 'Gym Leader Castle'),
'Cerulean Gym': LocData(20000715, 'Gym Leader Castle'),
'SURGE': LocData(20000724, 'Gym Leader Castle'),
'Vermillion Gym': LocData(20000725, 'Gym Leader Castle'),
'ERIKA': LocData(20000734, 'Gym Leader Castle'),
'Celadon Gym': LocData(20000735, 'Gym Leader Castle'),
'KOGA': LocData(20000744, 'Gym Leader Castle'),
'Fuchsia Gym': LocData(20000745, 'Gym Leader Castle'),
'SABRINA': LocData(20000754, 'Gym Leader Castle'),
'Saffron Gym': LocData(20000755, 'Gym Leader Castle'),
'BLAINE': LocData(20000764, 'Gym Leader Castle'),
'Cinnabar Gym': LocData(20000765, 'Gym Leader Castle'),
'GIOVANNI': LocData(20000774, 'Gym Leader Castle'),
'Viridian Gym': LocData(20000775, 'Gym Leader Castle'),
}
event_locations = {
'Beat Rival': LocData(20000000, 'Hall of Fame')
}
trainersanity_locations = {
'Poké Cup - Poké Ball - Bug Boy': LocData(20000301, 'Poké Cup'),
'Poké Cup - Poké Ball - Lad': LocData(20000302, 'Poké Cup'),
'Poké Cup - Poké Ball - Nerd': LocData(20000303, 'Poké Cup'),
'Poké Cup - Poké Ball - Sailor': LocData(20000304, 'Poké Cup'),
'Poké Cup - Poké Ball - Jr(F)': LocData(20000305, 'Poké Cup'),
'Poké Cup - Poké Ball - Jr(M)': LocData(20000306, 'Poké Cup'),
'Poké Cup - Poké Ball - Lass': LocData(20000307, 'Poké Cup'),
'Poké Cup - Poké Ball - Pokémaniac': LocData(20000308, 'Poké Cup'),
'Poké Cup - Great Ball - Bug Boy': LocData(20000311, 'Poké Cup'),
'Poké Cup - Great Ball - Lad': LocData(20000312, 'Poké Cup'),
'Poké Cup - Great Ball - Nerd': LocData(20000313, 'Poké Cup'),
'Poké Cup - Great Ball - Sailor': LocData(20000314, 'Poké Cup'),
'Poké Cup - Great Ball - Jr(F)': LocData(20000315, 'Poké Cup'),
'Poké Cup - Great Ball - Jr(M)': LocData(20000316, 'Poké Cup'),
'Poké Cup - Great Ball - Lass': LocData(20000317, 'Poké Cup'),
'Poké Cup - Great Ball - Pokémaniac': LocData(20000318, 'Poké Cup'),
'Poké Cup - Ultra Ball - Bug Boy': LocData(20000321, 'Poké Cup'),
'Poké Cup - Ultra Ball - Lad': LocData(20000322, 'Poké Cup'),
'Poké Cup - Ultra Ball - Nerd': LocData(20000323, 'Poké Cup'),
'Poké Cup - Ultra Ball - Sailor': LocData(20000324, 'Poké Cup'),
'Poké Cup - Ultra Ball - Jr(F)': LocData(20000325, 'Poké Cup'),
'Poké Cup - Ultra Ball - Jr(M)': LocData(20000326, 'Poké Cup'),
'Poké Cup - Ultra Ball - Lass': LocData(20000327, 'Poké Cup'),
'Poké Cup - Ultra Ball - Pokémaniac': LocData(20000328, 'Poké Cup'),
'Poké Cup - Master Ball - Bug Boy': LocData(20000331, 'Poké Cup'),
'Poké Cup - Master Ball - Lad': LocData(20000332, 'Poké Cup'),
'Poké Cup - Master Ball - Nerd': LocData(20000333, 'Poké Cup'),
'Poké Cup - Master Ball - Sailor': LocData(20000334, 'Poké Cup'),
'Poké Cup - Master Ball - Jr(F)': LocData(20000335, 'Poké Cup'),
'Poké Cup - Master Ball - Jr(M)': LocData(20000336, 'Poké Cup'),
'Poké Cup - Master Ball - Lass': LocData(20000337, 'Poké Cup'),
'Poké Cup - Master Ball - Pokémaniac': LocData(20000338, 'Poké Cup'),
'Petit Cup - Bug Boy': LocData(20000401, 'Petit Cup'),
'Petit Cup - Lad': LocData(20000402, 'Petit Cup'),
'Petit Cup - Nerd': LocData(20000403, 'Petit Cup'),
'Petit Cup - Sailor': LocData(20000404, 'Petit Cup'),
'Petit Cup - Jr(F)': LocData(20000405, 'Petit Cup'),
'Petit Cup - Jr(M)': LocData(20000406, 'Petit Cup'),
'Petit Cup - Lass': LocData(20000407, 'Petit Cup'),
'Petit Cup - Pokémaniac': LocData(20000408, 'Petit Cup'),
'Pika Cup - Bug Boy': LocData(20000501, 'Pika Cup'),
'Pika Cup - Lad': LocData(20000502, 'Pika Cup'),
'Pika Cup - Swimmer': LocData(20000503, 'Pika Cup'),
'Pika Cup - Burglar': LocData(20000504, 'Pika Cup'),
'Pika Cup - Mr. Fix': LocData(20000505, 'Pika Cup'),
'Pika Cup - Hiker': LocData(20000506, 'Pika Cup'),
'Pika Cup - Lass': LocData(20000507, 'Pika Cup'),
'Pika Cup - Fisher': LocData(20000508, 'Pika Cup'),
'Prime Cup - Poké Ball - Cue Ball': LocData(20000601, 'Prime Cup'),
'Prime Cup - Poké Ball - Rocket': LocData(20000602, 'Prime Cup'),
'Prime Cup - Poké Ball - Judoboy': LocData(20000603, 'Prime Cup'),
'Prime Cup - Poké Ball - Gambler': LocData(20000604, 'Prime Cup'),
'Prime Cup - Poké Ball - Cool(F)': LocData(20000605, 'Prime Cup'),
'Prime Cup - Poké Ball - Bird Boy': LocData(20000606, 'Prime Cup'),
'Prime Cup - Poké Ball - Lab Man': LocData(20000607, 'Prime Cup'),
'Prime Cup - Poké Ball - Cool(M)': LocData(20000608, 'Prime Cup'),
'Prime Cup - Great Ball - Cue Ball': LocData(20000611, 'Prime Cup'),
'Prime Cup - Great Ball - Rocket': LocData(20000612, 'Prime Cup'),
'Prime Cup - Great Ball - Judoboy': LocData(20000613, 'Prime Cup'),
'Prime Cup - Great Ball - Gambler': LocData(20000614, 'Prime Cup'),
'Prime Cup - Great Ball - Cool(F)': LocData(20000615, 'Prime Cup'),
'Prime Cup - Great Ball - Bird Boy': LocData(20000616, 'Prime Cup'),
'Prime Cup - Great Ball - Lab Man': LocData(20000617, 'Prime Cup'),
'Prime Cup - Great Ball - Cool(M)': LocData(20000618, 'Prime Cup'),
'Prime Cup - Ultra Ball - Cue Ball': LocData(20000621, 'Prime Cup'),
'Prime Cup - Ultra Ball - Rocket': LocData(20000622, 'Prime Cup'),
'Prime Cup - Ultra Ball - Judoboy': LocData(20000623, 'Prime Cup'),
'Prime Cup - Ultra Ball - Gambler': LocData(20000624, 'Prime Cup'),
'Prime Cup - Ultra Ball - Cool(F)': LocData(20000625, 'Prime Cup'),
'Prime Cup - Ultra Ball - Bird Boy': LocData(20000626, 'Prime Cup'),
'Prime Cup - Ultra Ball - Lab Man': LocData(20000627, 'Prime Cup'),
'Prime Cup - Ultra Ball - Cool(M)': LocData(20000628, 'Prime Cup'),
'Prime Cup - Master Ball - Cue Ball': LocData(20000631, 'Prime Cup'),
'Prime Cup - Master Ball - Rocket': LocData(20000632, 'Prime Cup'),
'Prime Cup - Master Ball - Judoboy': LocData(20000633, 'Prime Cup'),
'Prime Cup - Master Ball - Gambler': LocData(20000634, 'Prime Cup'),
'Prime Cup - Master Ball - Cool(F)': LocData(20000635, 'Prime Cup'),
'Prime Cup - Master Ball - Bird Boy': LocData(20000636, 'Prime Cup'),
'Prime Cup - Master Ball - Lab Man': LocData(20000637, 'Prime Cup'),
'Prime Cup - Master Ball - Cool(M)': LocData(20000638, 'Prime Cup'),
'Pewter Gym - Bug Boy': LocData(20000701, 'Gym Leader Castle'),
'Pewter Gym - Lad': LocData(20000702, 'Gym Leader Castle'),
'Pewter Gym - Jr(M)': LocData(20000703, 'Gym Leader Castle'),
'Cerulean Gym - Fisher': LocData(20000711, 'Gym Leader Castle'),
'Cerulean Gym - Jr(F)': LocData(20000712, 'Gym Leader Castle'),
'Cerulean Gym - Swimmer': LocData(20000713, 'Gym Leader Castle'),
'Vermillion Gym - Sailor': LocData(20000721, 'Gym Leader Castle'),
'Vermillion Gym - Rocker': LocData(20000722, 'Gym Leader Castle'),
'Vermillion Gym - Old Man': LocData(20000723, 'Gym Leader Castle'),
'Celadon Gym - Lass': LocData(20000731, 'Gym Leader Castle'),
'Celadon Gym - Beauty': LocData(20000732, 'Gym Leader Castle'),
'Celadon Gym - Cool(F)': LocData(20000733, 'Gym Leader Castle'),
'Fuchsia Gym - Biker': LocData(20000741, 'Gym Leader Castle'),
'Fuchsia Gym - Tamer': LocData(20000742, 'Gym Leader Castle'),
'Fuchsia Gym - Juggler': LocData(20000743, 'Gym Leader Castle'),
'Saffron Gym - Cue Ball': LocData(20000751, 'Gym Leader Castle'),
'Saffron Gym - Burglar': LocData(20000752, 'Gym Leader Castle'),
'Saffron Gym - Medium': LocData(20000753, 'Gym Leader Castle'),
'Cinnabar Gym - Judoboy': LocData(20000761, 'Gym Leader Castle'),
'Cinnabar Gym - Psychic': LocData(20000762, 'Gym Leader Castle'),
'Cinnabar Gym - Nerd': LocData(20000763, 'Gym Leader Castle'),
'Viridian Gym - Rocket': LocData(20000771, 'Gym Leader Castle'),
'Viridian Gym - Lab Man': LocData(20000772, 'Gym Leader Castle'),
'Viridian Gym - Cool(M)': LocData(20000773, 'Gym Leader Castle'),
}
location_table = {
**pokemon_stadium_locations,
**event_locations
}

View File

@@ -0,0 +1,342 @@
from typing import List, Dict, Any
from dataclasses import dataclass
from worlds.AutoWorld import PerGameCommonOptions
from Options import Choice, OptionGroup, Toggle, Range
def create_option_groups() -> List[OptionGroup]:
option_group_list: List[OptionGroup] = []
for name, options in pokemon_stadium_option_groups.items():
option_group_list.append(OptionGroup(name=name, options=options))
return option_group_list
class VictoryCondition(Choice):
"""
Choose victory condition
"""
display_name = "Victory Condition"
option_defeat_rival = 1
option_clear_master_ball_cup = 2
default = 1
class BaseStatTotalRandomness(Choice):
"""
Controls the level of randomness for Pokemon BST. Stat distribution per Pokemon will follow a randomly selected distribution curve.
The higher the selection, the more extreme a curve you may see used.
Stat changes are universal. Rental Pokemon and enemy trainer team Pokemon use the same BSTs.
Vanilla - No change
Low - 3 distribution types
Medium - 4 distribution types
High - 5 distribution types
"""
display_name = "BST Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class Trainersanity(Toggle):
"""
Toggle on to make all Trainers into checks. This option is off by default.
"""
display_name = 'Trainersanity'
option_off = 0
option_on = 1
default = 0
class GymCastleTrainerRandomness(Choice):
"""
Controls the level of randomness for the enemy team and movesets in Gym Leader Castle.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Gym Castle Trainer Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PokeCupTrainerRandomness(Choice):
"""
Controls the level of randomness for the enemy team and movesets in Poke Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Poke Cup Trainer Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PrimeCupTrainerRandomness(Choice):
"""
Controls the level of randomness for the enemy team and movesets in Prime Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Prime Cup Trainer Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PetitCupTrainerRandomness(Choice):
"""
Controls the level of randomness for the enemy team and movesets in Petit Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Petit Cup Trainer Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PikaCupTrainerRandomness(Choice):
"""
Controls the level of randomness for the enemy team and movesets in Pika Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Pika Cup Trainer Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class GymCastleRentalRandomness(Choice):
"""
Controls the level of randomness for the rental Pokemon moves in Gym Leader Castle.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Gym Castle Rental Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PokeCupRentalRandomness(Choice):
"""
Controls the level of randomness for the rental Pokemon moves in the Poke Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Poke Cup Rental Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PrimeCupRentalRandomness(Choice):
"""
Controls the level of randomness for the rental Pokemon moves in the Prime Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Prime Cup Rental Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PetitCupRentalRandomness(Choice):
"""
Controls the level of randomness for the rental Pokemon moves in the Petit Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Petit Cup Rental Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class PikaCupRentalRandomness(Choice):
"""
Controls the level of randomness for the rental Pokemon moves in the Pika Cup.
Vanilla - No change
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
"""
display_name = "Pika Cup Rental Randomness"
option_vanilla = 1
option_low = 2
option_medium = 3
option_high = 4
default = 1
class RentalListShuffle(Choice):
"""
Controls whether the rental pokemon list is randomized or not
Instead of going in dex order, the rental tables will be shuffled
Off - No change
On - All tables shuffled
Manual: Select which tables are shuffled
"""
display_name = "Rental List Shuffle"
option_off = 1
option_on = 2
option_manual = 3
default = 1
class RentalListShuffleGLC(Choice):
"""
Controls whether the rental pokemon list for the Gym Leader Castle is randomized or not
Instead of going in dex order, the rental tables will be shuffled
This option only matters if RentalListShuffle is set to Manual mode.
Default is set to On
Off - No change
On - All tables shuffled
"""
display_name = "RLS Manual: Gym Leader Castle"
option_off = 1
option_on = 2
default = 2
class RentalListShufflePokeCup(Choice):
"""
Controls whether the rental pokemon list for the Poke Cup is randomized or not
Instead of going in dex order, the rental tables will be shuffled
This option only matters if RentalListShuffle is set to Manual mode.
Default is set to On
Off - No change
On - All tables shuffled
"""
display_name = "RLS Manual: Poke Cup"
option_off = 1
option_on = 2
default = 2
class RentalListShufflePrimeCup(Choice):
"""
Controls whether the rental pokemon list for the Prime Cup is randomized or not
Instead of going in dex order, the rental tables will be shuffled
This option only matters if RentalListShuffle is set to Manual mode.
Default is set to On
Off - No change
On - All tables shuffled
"""
display_name = "RLS Manual: Prime Cup"
option_off = 1
option_on = 2
default = 2
class RentalListShufflePetitCup(Choice):
"""
Controls whether the rental pokemon list for the Petit Cup is randomized or not
Instead of going in dex order, the rental tables will be shuffled
This option only matters if RentalListShuffle is set to Manual mode.
Default is set to On
Off - No change
On - All tables shuffled
"""
display_name = "RLS Manual: Petit Cup"
option_off = 1
option_on = 2
default = 2
class RentalListShufflePikaCup(Choice):
"""
Controls whether the rental pokemon list for the Pika Cup is randomized or not
Instead of going in dex order, the rental tables will be shuffled
This option only matters if RentalListShuffle is set to Manual mode.
Default is set to On
Off - No change
On - All tables shuffled
"""
display_name = "RLS Manual: Pika Cup"
option_off = 1
option_on = 2
default = 2
@dataclass
class PokemonStadiumOptions(PerGameCommonOptions):
VictoryCondition: VictoryCondition
BaseStatTotalRandomness: BaseStatTotalRandomness
Trainersanity: Trainersanity
GymCastleTrainerRandomness: GymCastleTrainerRandomness
PokeCupTrainerRandomness: PokeCupTrainerRandomness
PrimeCupTrainerRandomness: PrimeCupTrainerRandomness
PetitCupTrainerRandomness: PetitCupTrainerRandomness
PikaCupTrainerRandomness: PikaCupTrainerRandomness
GymCastleRentalRandomness: GymCastleRentalRandomness
PokeCupRentalRandomness: PokeCupRentalRandomness
PrimeCupRentalRandomness: PrimeCupRentalRandomness
PetitCupRentalRandomness: PetitCupRentalRandomness
PikaCupRentalRandomness: PikaCupRentalRandomness
RentalListShuffle: RentalListShuffle
RentalListShuffleGLC: RentalListShuffleGLC
RentalListShufflePokeCup: RentalListShufflePokeCup
RentalListShufflePrimeCup: RentalListShufflePrimeCup
RentalListShufflePetitCup: RentalListShufflePetitCup
RentalListShufflePikaCup: RentalListShufflePikaCup
# This is where you organize your options
# Its entirely up to you how you want to organize it
pokemon_stadium_option_groups: Dict[str, List[Any]] = {
"General Options": [
VictoryCondition,
BaseStatTotalRandomness,
Trainersanity,
],
"Enemy Trainer Pokemon Options": [
GymCastleTrainerRandomness,
PokeCupTrainerRandomness,
PrimeCupTrainerRandomness,
PetitCupTrainerRandomness,
PikaCupTrainerRandomness,
],
"Rental Pokemon Options":
[
GymCastleRentalRandomness,
PokeCupRentalRandomness,
PrimeCupRentalRandomness,
PetitCupRentalRandomness,
PikaCupRentalRandomness,
],
"Shuffling Options":
[ RentalListShuffle,
RentalListShuffleGLC,
RentalListShufflePokeCup,
RentalListShufflePrimeCup,
RentalListShufflePetitCup,
RentalListShufflePikaCup],
}

View File

@@ -0,0 +1,49 @@
from BaseClasses import Region
from .Types import PokemonStadiumLocation
from .Locations import location_table, trainersanity_locations, is_valid_location
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from . import PokemonStadiumWorld
def create_regions(world: "PokemonStadiumWorld"):
menu = create_region(world, "Menu")
# ---------------------------------- Gym Leader Castle ----------------------------------
gym_leader_castle = create_region_and_connect(world, "Gym Leader Castle", "Menu -> Gym Leader Castle", menu)
create_region_and_connect(world, "Elite Four", "Gym Leader Castle -> Elite Four", gym_leader_castle)
create_region_and_connect(world, "Rival", "Elite Four -> Rival", gym_leader_castle)
create_region_and_connect(world, "Hall of Fame", "Rival -> Hall of Fame", gym_leader_castle)
create_region_and_connect(world, "Beat Rival", "Hall of Fame -> Beat Rival", gym_leader_castle)
# -------------------------------------- Kids Club --------------------------------------
create_region_and_connect(world, "Kids Club", "Menu -> Kids Club", menu)
# --------------------------------------- Stadium ---------------------------------------
stadium = create_region_and_connect(world, "Stadium", "Menu -> Stadium", menu)
create_region_and_connect(world, "Poké Cup", "Stadium -> Poké Cup", stadium)
create_region_and_connect(world, "Petit Cup", "Stadium -> Petit Cup", stadium)
create_region_and_connect(world, "Pika Cup", "Stadium -> Pika Cup", stadium)
create_region_and_connect(world, "Prime Cup", "Stadium -> Prime Cup", stadium)
def create_region(world: "PokemonStadiumWorld", name: str) -> Region:
reg = Region(name, world.player, world.multiworld)
if world.options.Trainersanity.value == 1:
location_table.update(trainersanity_locations)
for (key, data) in location_table.items():
if data.region == name:
if not is_valid_location(world, key):
continue
location = PokemonStadiumLocation(world.player, key, data.ap_code, reg)
reg.locations.append(location)
world.multiworld.regions.append(reg)
return reg
def create_region_and_connect(world: "PokemonStadiumWorld", name: str, entrancename: str, connected_region: Region) -> Region:
reg: Region = create_region(world, name)
connected_region.connect(reg, entrancename)
return reg

View File

@@ -0,0 +1,136 @@
import hashlib
import os
from settings import get_settings
import Utils
from worlds.AutoWorld import World
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
from .randomizer import stadium_randomizer
NOP = bytes([0x00,0x00,0x00,0x00])
MD5Hash = "ed1378bc12115f71209a77844965ba50"
class PokemonStadiumProcedurePatch(APProcedurePatch, APTokenMixin):
game = "Pokemon Stadium"
hash = MD5Hash
patch_file_ending = ".apstadium"
result_file_ending = ".z64"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes()
def get_base_rom_bytes() -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
file_name = get_base_rom_path()
base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb")))
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
md5hash = basemd5.hexdigest()
if MD5Hash !=md5hash:
raise Exception("Supplied Rom does not match known MD5 for Pokemon Stadium")
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
return base_rom_bytes
def get_base_rom_path():
file_name = get_settings()["stadium_options"]["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name
def write_tokens(world:World, patch:PokemonStadiumProcedurePatch):
# version = settings['ROMVersion']
bst_factor = world.options.BaseStatTotalRandomness.value
glc_trainer_factor = world.options.GymCastleTrainerRandomness.value
pokecup_trainer_factor = world.options.PokeCupTrainerRandomness.value
primecup_trainer_factor = world.options.PrimeCupTrainerRandomness.value
petitcup_trainer_factor = world.options.PetitCupTrainerRandomness.value
pikacup_trainer_factor = world.options.PikaCupTrainerRandomness.value
glc_rental_factor = world.options.GymCastleRentalRandomness.value
pokecup_rental_factor = world.options.PokeCupRentalRandomness.value
primecup_rental_factor = world.options.PrimeCupRentalRandomness.value
petitcup_rental_factor = world.options.PetitCupRentalRandomness.value
pikacup_rental_factor = world.options.PikaCupRentalRandomness.value
rental_list_shuffle_factor = world.options.RentalListShuffle.value
rental_list_shuffle_glc_factor = world.options.RentalListShuffleGLC.value
rental_list_shuffle_poke_cup_factor = world.options.RentalListShufflePokeCup.value
rental_list_shuffle_prime_cup_factor = world.options.RentalListShufflePrimeCup.value
rental_list_shuffle_petit_cup_factor = world.options.RentalListShufflePetitCup.value
rental_list_shuffle_pika_cup_factor = world.options.RentalListShufflePikaCup.value
randomizer = stadium_randomizer.Randomizer('US_1.0', bst_factor, glc_trainer_factor, pokecup_trainer_factor, primecup_trainer_factor, petitcup_trainer_factor,
pikacup_trainer_factor, glc_rental_factor, pokecup_rental_factor, primecup_rental_factor,petitcup_rental_factor, pikacup_rental_factor,
rental_list_shuffle_factor, rental_list_shuffle_glc_factor, rental_list_shuffle_poke_cup_factor, rental_list_shuffle_prime_cup_factor,
rental_list_shuffle_petit_cup_factor, rental_list_shuffle_pika_cup_factor)
# Bypass CIC
randomizer.disable_checksum(patch)
if bst_factor > 1:
randomizer.randomize_base_stats(patch)
if glc_trainer_factor > 1:
randomizer.randomize_glc_trainer_pokemon_round1(patch)
if pokecup_trainer_factor > 1:
randomizer.randomize_pokecup_trainer_pokemon_round1(patch)
if primecup_trainer_factor > 1:
randomizer.randomize_primecup_trainer_pokemon_round1(patch)
if petitcup_trainer_factor > 1:
randomizer.randomize_petitcup_trainer_pokemon_round1(patch)
if pikacup_trainer_factor > 1:
randomizer.randomize_pikacup_trainer_pokemon_round1(patch)
if glc_rental_factor > 1:
randomizer.randomize_glc_rentals_round1(patch)
if pokecup_rental_factor > 1:
randomizer.randomize_pokecup_rentals(patch)
if primecup_rental_factor > 1:
randomizer.randomize_primecup_rentals_round1(patch)
if petitcup_rental_factor > 1:
randomizer.randomize_petitcup_rentals(patch)
if pikacup_rental_factor > 1:
randomizer.randomize_pikacup_rentals(patch)
if rental_list_shuffle_factor > 1:
if rental_list_shuffle_factor != 3: #Not in manual mode
randomizer.shuffle_rentals(patch)
else:
if rental_list_shuffle_glc_factor > 1:
randomizer.shuffle_glc(patch)
if rental_list_shuffle_poke_cup_factor > 1:
randomizer.shuffle_poke(patch)
if rental_list_shuffle_prime_cup_factor > 1:
randomizer.shuffle_prime(patch)
if rental_list_shuffle_petit_cup_factor > 1:
randomizer.shuffle_petit(patch)
if rental_list_shuffle_pika_cup_factor > 1:
randomizer.shuffle_pika(patch)
# Set GP Register to 80420000
patch.write_token(APTokenTypes.WRITE, 0x202B8, bytes([0x3C, 0x1C, 0x80, 0x42]))
# Set 'Starting Battle' flag
patch.write_token(APTokenTypes.WRITE, 0x855C, bytes([0xAF, 0x81, 0x00, 0x10]))
# Clear 'Starting Battle' flag
patch.write_token(APTokenTypes.WRITE, 0x396D08, bytes([0xAF, 0x80, 0x00, 0x10]))
# Turn off A and B button on GLC select screen
patch.write_token(APTokenTypes.WRITE, 0x3B4DA8, bytes([0x50, 0x21, 0xFF, 0x82]))
# First instruction to set flag for GLC selection screen
patch.write_token(APTokenTypes.WRITE, 0x3B5548, bytes([0xAF, 0x84, 0x00, 0x00]))
# Second instruction to set flag for GLC selection screen
patch.write_token(APTokenTypes.WRITE, 0x3B55F4, bytes([0xAF, 0x82, 0x00, 0x00]))
# Set selecting Poke Cup tier flag
patch.write_token(APTokenTypes.WRITE, 0x2D6A20, bytes([0xAF, 0x93, 0x00, 0x20]))
# Clear selecting Poke Cup tier flag
patch.write_token(APTokenTypes.WRITE, 0x2D6DB0, bytes([0xAF, 0x80, 0x00, 0x20]))
# Stop game from activating unlocked gyms
patch.write_token(APTokenTypes.WRITE, 0x3B5728, bytes([0xA3, 0x20, 0x00, 0x01]))
# Write patch file
patch.write_file("token_data.bin", patch.get_token_binary())

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