Compare commits

...

103 Commits
0.6.6 ... main

Author SHA1 Message Date
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
512 changed files with 79633 additions and 10992 deletions

View File

@@ -2,11 +2,15 @@
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/general/test_rule_builder.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.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

3
.gitignore vendored
View File

@@ -63,7 +63,10 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/datapackage_export.json
/custom_worlds
# stubgen output
/out/
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@@ -1,5 +1,5 @@
<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" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />

View File

@@ -8,10 +8,10 @@ import secrets
import warnings
from argparse import Namespace
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 typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -22,6 +22,7 @@ import Utils
if TYPE_CHECKING:
from entrance_rando import ERPlacementState
from rule_builder.rules import Rule
from worlds import AutoWorld
@@ -85,7 +86,7 @@ class MultiWorld():
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]]
completion_condition: Dict[int, CollectionRule]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations]
@@ -766,7 +767,7 @@ class CollectionState():
else:
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]
blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items
@@ -784,13 +785,16 @@ class CollectionState():
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
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
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
entrances = self.multiworld.indirect_connections.get(new_region)
if entrances is not None:
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]
blocked_connections = self.blocked_connections[player]
new_connection: bool = True
@@ -812,6 +816,7 @@ class CollectionState():
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
new_connection = True
self.multiworld.worlds[player].reached_region(self, new_region)
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections)
@@ -1169,13 +1174,17 @@ class CollectionState():
self.prog_items[player][item] = count
CollectionRule = Callable[[CollectionState], bool]
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
class EntranceType(IntEnum):
ONE_WAY = 1
TWO_WAY = 2
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
hide_path: bool = False
player: int
name: str
@@ -1362,7 +1371,7 @@ class Region:
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
rule: CollectionRule | Rule[Any] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
@@ -1390,7 +1399,7 @@ class Region:
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
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)
@@ -1401,7 +1410,7 @@ class Region:
return event_item
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.
@@ -1409,8 +1418,8 @@ class Region:
: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"""
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
if rule:
exit_.access_rule = rule
if rule is not None:
self.multiworld.worlds[self.player].set_rule(exit_, rule)
exit_.connect(connecting_region)
return exit_
@@ -1435,7 +1444,7 @@ class Region:
return entrance
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.
@@ -1474,7 +1483,7 @@ class Location:
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
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: Optional[Item] = None
@@ -1551,7 +1560,7 @@ class ItemClassification(IntFlag):
skip_balancing = 0b01000
""" should technically never occur on its own
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:
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) """
@@ -1559,13 +1568,13 @@ class ItemClassification(IntFlag):
deprioritized = 0b10000
""" Should technically never occur on its own.
Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced

View File

@@ -24,7 +24,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor, mark_raw
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
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
import os
import ssl
@@ -35,9 +35,6 @@ if typing.TYPE_CHECKING:
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
def get_ssl_context():
@@ -65,6 +62,8 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_exit(self) -> bool:
"""Close connections and client"""
if self.ctx.ui:
self.ctx.ui.stop()
self.ctx.exit_event.set()
return True

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
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
settings = get_settings()
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)
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.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
args.plando = PlandoOptions.from_option_string(args.plando)
return args
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
meta_weights = None
player_id = 1
player_files = {}
player_id: int = 1
player_files: dict[int, str] = {}
player_errors: list[str] = []
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
@@ -135,9 +135,13 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
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:
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)
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(
"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."
@@ -161,6 +169,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"{seed_name} Seed {seed} with plando: {args.plando}")
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. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
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.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:
for category_name, category_dict in meta_weights.items():
for key in category_dict:
@@ -197,47 +205,85 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
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):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
name_counter: Counter[str] = Counter()
args.player_options = {}
player = 1
while player <= args.multi:
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:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
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
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
# Invariant: settings_cache[path] and weights_cache[path] have the same length
cached = settings_cache[path]
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
# 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)
for k, v in vars(settings_object).items():
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
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:
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
f"(name: {args.name.get(player, name)})")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
f"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):
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
@@ -316,7 +362,7 @@ class SafeFormatter(string.Formatter):
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
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
@@ -454,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
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:
if option_key in game_weights:
if not option.supports_weighting:

View File

@@ -31,6 +31,10 @@ import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
if __name__ == "__main__":
init_logging('Launcher')
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
@@ -493,7 +497,6 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(

View File

@@ -5,15 +5,16 @@ import multiprocessing
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.
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):
# 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.")
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.
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)
_skip_update = bool(

View File

@@ -69,6 +69,12 @@ def remove_from_list(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:
container.pop(value)
except ValueError:
@@ -911,12 +917,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
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.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -1364,7 +1364,10 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
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"
return s

View File

@@ -24,6 +24,39 @@ if typing.TYPE_CHECKING:
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:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
@@ -417,10 +450,12 @@ class Toggle(NumericOption):
def from_text(cls, text: str) -> Toggle:
if text == "random":
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)
else:
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
return cls(1)
else:
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
@classmethod
def from_any(cls, data: typing.Any):
@@ -523,9 +558,9 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""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), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
@@ -546,7 +581,7 @@ class TextChoice(Choice):
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
def get_option_name(cls, value: str | int) -> str:
if isinstance(value, str):
return value
return super().get_option_name(value)
@@ -688,12 +723,6 @@ class Range(NumericOption):
range_start = 0
range_end = 1
_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 __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
@@ -742,25 +771,16 @@ class Range(NumericOption):
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
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-"):
if text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
@@ -768,14 +788,9 @@ class Range(NumericOption):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
elif text.startswith("random-range-middle"):
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]))
if textsplit[2] in ("low", "middle", "high"):
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
return cls(random_weighted_range("random", *random_range))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
@@ -790,18 +805,6 @@ class Range(NumericOption):
def __str__(self) -> str:
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):
special_range_names: typing.Dict[str, int] = {}
@@ -891,7 +894,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
supports_weighting = False
@@ -906,7 +909,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
else:
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())
def __getitem__(self, item: str) -> typing.Any:
@@ -986,7 +990,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
return cls(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))
def __contains__(self, item):
@@ -996,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
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.random_str = random_str
super(OptionSet, self).__init__()
@classmethod
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(",")])
@classmethod
@@ -1011,7 +1022,37 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return cls(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))
def __contains__(self, item):
@@ -1656,7 +1697,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1742,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from Utils import local_path, __version__
full_path: str
preset_folder = os.path.join(target_folder, "Presets")
os.makedirs(target_folder, exist_ok=True)
os.makedirs(preset_folder, exist_ok=True)
# clean out old
for file in os.listdir(target_folder):
@@ -1751,11 +1794,16 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: Range):
data = {option.default: 50}
for file in os.listdir(preset_folder):
full_path = os.path.join(preset_folder, file)
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
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.default:
if sub_option != option_val:
data[sub_option] = 0
notes = {
"random-low": "random value weighted towards lower values",
@@ -1768,6 +1816,8 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if number in data:
data[name] = data[number]
del data[number]
elif name in data:
pass
else:
data[name] = 0
@@ -1783,20 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
presets = world.web.options_presets.copy()
presets.update({"": {}})
option_groups = get_option_groups(world)
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:

View File

@@ -6,6 +6,7 @@ if __name__ == "__main__":
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
@@ -269,34 +270,53 @@ class OptionsCreator(ThemedApp):
self.options = {}
super().__init__()
def export_options(self, button: Widget):
if 0 < len(self.name_input.text) < 17 and self.current_game:
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
@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()}
}
try:
with open(file_name, 'w') as f:
f.write(Utils.dump(options, sort_keys=False))
f.close()
MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
except FileNotFoundError:
MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
self.container.disabled = True
elif not self.name_input.text:
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
self.show_result_snack("Name must not be empty.")
elif not self.current_game:
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
self.show_result_snack("You must select a game to play.")
else:
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
self.show_result_snack("Name cannot be longer than 16 characters.")
def create_range(self, option: typing.Type[Range], name: str):
def update_text(range_box: VisualRange):
@@ -509,8 +529,10 @@ class OptionsCreator(ThemedApp):
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric() or self.options[name] in ("True", "False"):
self.options[name] = eval(self.options[name])
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
@@ -632,7 +654,7 @@ class OptionsCreator(ThemedApp):
self.create_options_panel(world_btn)
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
if world == "Archipelago":
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})

View File

@@ -83,6 +83,8 @@ Currently, the following games are supported:
* Celeste (Open World)
* Choo-Choo Charles
* APQuest
* Satisfactory
* EarthBound
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

174
Utils.py
View File

@@ -22,6 +22,7 @@ from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
@@ -48,7 +49,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.6"
__version__ = "0.6.7"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -387,6 +388,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
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:
import LttPAdjuster
adjuster_settings = Namespace()
@@ -802,29 +811,32 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
try:
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}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
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(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
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(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
@@ -847,8 +859,14 @@ def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
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:
@@ -896,6 +914,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
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():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -931,6 +956,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
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"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
@@ -1050,9 +1078,18 @@ def freeze_support() -> None:
_extend_freeze_support()
def visualize_regions(root_region: Region, 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) -> None:
def visualize_regions(
root_region: Region,
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.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -1069,6 +1106,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 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 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:
from Utils import visualize_regions
@@ -1094,6 +1138,34 @@ def visualize_regions(root_region: Region, file_name: str, *,
regions: typing.Deque[Region] = deque((root_region,))
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:
name = obj.name
if isinstance(obj, Item):
@@ -1113,18 +1185,28 @@ def visualize_regions(root_region: Region, file_name: str, *,
def visualize_exits(region: Region) -> None:
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 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:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
uml.append(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)}\"{color_code}")
except ValueError:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
else:
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
uml.append(f"\"{fmt(region)}\" --> \"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_)}\"{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:
any_lock = any(location.locked for location in region.locations)
@@ -1145,9 +1227,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]:
uml.append("package \"other regions\" <<Cloud>> {")
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("}")
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("hide circle")
uml.append("hide empty members")
@@ -1158,7 +1258,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
seen.add(current_region)
visualize_region(current_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()
uml.append("@enduml")
@@ -1222,3 +1322,35 @@ class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
t.start()
self._threads.add(t)
# 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__)
settings.no_gui = True
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'))

View File

@@ -1,46 +1,20 @@
# 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
**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
Little changes like adding a button or a couple new select elements are perfectly fine.
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.
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
Design changes have to fit the overall design.
### Content Additions
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.
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
### Restrictions on Style Changes
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.
See also [docs/style.md](/docs/style.md) for the style guide.

View File

@@ -23,6 +23,17 @@ app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
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["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
@@ -30,19 +41,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["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
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
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
# archipelago.gg uses gunicorn + nginx; ignoring this option

View File

@@ -89,19 +89,24 @@ class WebHostContext(Context):
setattr(self, key, value)
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)
while not self.exit_event.is_set():
with db_session:
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()
del commands
time.sleep(5)
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
try:
await asyncio.wait_for(self.exit_event.wait(), 5)
except asyncio.TimeoutError:
pass
def _process_db_commands(self, cmdprocessor):
with db_session:
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
def load(self, room_id: int):
@@ -156,9 +161,9 @@ class WebHostContext(Context):
with db_session:
savegame_data = Room.get(id=self.room_id).multisave
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)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
asyncio.create_task(self.listen_to_db_commands())
@db_session
def _save(self, exit_save: bool = False) -> bool:
@@ -229,6 +234,17 @@ def set_up_logging(room_id) -> logging.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,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
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):
if ctx.saving:
ctx._save()
ctx._save(True)
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
@@ -336,19 +352,25 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise
else:
if ctx.saving:
ctx._save()
ctx._save(True)
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
if ctx.server and hasattr(ctx.server, "ws_server"):
ctx.server.ws_server.close()
await ctx.server.ws_server.wait_closed()
with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)

View File

@@ -128,8 +128,13 @@ def tutorial_landing():
"authors": tutorial.authors,
"language": tutorial.language
}
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
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)

View File

@@ -55,6 +55,9 @@
{{ OptionTitle(option_name, option) }}
<div class="named-range-container">
<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() %}
{% if option.default == val %}
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
@@ -94,6 +97,9 @@
<div class="text-choice-container">
<div class="text-choice-wrapper">
<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 %}
{% if name != "random" %}
{% if option.default == id %}

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

@@ -28,7 +28,7 @@
name: Player{number}
# 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) }}
requires:
@@ -38,11 +38,11 @@ requires:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %}
{%- macro range_option(option) %}
{%- macro range_option(option, option_val) %}
# You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }}
# 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() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%}
@@ -56,6 +56,10 @@ requires:
{%- for option_key, option in group_options.items() %}
{{ option_key }}:
{%- set option_val = option.default %}
{%- if option_key in preset %}
{%- set option_val = preset[option_key] %}
{%- endif -%}
{%- if option.__doc__ %}
# {{ cleandoc(option.__doc__)
| trim
@@ -69,25 +73,25 @@ requires:
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}}
{{- range_option(option, option_val) -}}
{%- elif option.options -%}
{%- 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 -%}
{%- if option.name_lookup[option.default] not in option.options %}
{{ yaml_dump(option.default) }}: 50
{%- if option.name_lookup[option_val] not in option.options and option_val not in option.options %}
{{ yaml_dump(option_val) }}: 50
{%- endif -%}
{%- elif option.default is string %}
{{ yaml_dump(option.default) }}: 50
{%- elif option_val is string %}
{{ yaml_dump(option_val) }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- elif option_val is iterable and option_val is not mapping %}
{{ option_val | list }}
{%- else %}
{{ yaml_dump(option.default) | indent(4, first=false) }}
{{ yaml_dump(option_val) | indent(4, first=false) }}
{%- endif -%}
{{ "\n" }}
{%- endfor %}

View File

@@ -70,6 +70,9 @@
# DOOM II
/worlds/doom_ii/ @Daivuk @KScl
# EarthBound
/worlds/earthbound/ @PinkSwitch
# Factorio
/worlds/factorio/ @Berserker66
@@ -176,8 +179,12 @@
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
# Satisfactory
/worlds/satisfactory/ @Jarno458 @budak7273
# Starcraft 2
/worlds/sc2/ @Ziktofel
# Note: @Ziktofel acts as a mentor
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
# Super Metroid
/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
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
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
@@ -139,8 +140,8 @@ if possible.
* An implementation of
[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
filler items.
* By default, this function chooses any item name from `item_name_to_id`, which may include items you consider
"non-repeatable".
* 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
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)

View File

@@ -41,7 +41,7 @@ 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`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component),
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build APWorlds" Launcher Component
@@ -50,7 +50,9 @@ In the Archipelago Launcher, there is a "Build APWorlds" component that will pac
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
`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.
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`.
## 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

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
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 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
---
### 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
pushing.
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.**
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 |
| slots | list\[int\] | Optional. Player slot IDs that 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
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 |
| slots | list\[int\] | Optional. Player IDs 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
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.

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
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
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
* Matching C compiler
* 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).
## 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
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
* Indent with 2 spaces for new code.
* Indent with 4 spaces for new code.
* 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.
* No space between selector and `{`.
* Space between selector and `{`.
## JS
* Indent with 2 spaces.
* Indent `case` inside `switch ` with 2 spaces.
* Use single quotes.
* Indent with 4 spaces.
* Indent `case` inside `switch ` with 4 spaces.
* Prefer double quotation marks (`"`).
* 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

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 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:
- Datapackage API
@@ -24,13 +24,21 @@ Current endpoints:
- [`/get_rooms`](#getrooms)
- [`/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
These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations.
### `/datapackage`
<a name="datapackage"></a>
Fetches the current datapackage from the WebHost.
**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.
Each game will have:
- A checksum `checksum`
@@ -40,7 +48,7 @@ Each game will have:
- Location name to AP ID dict `location_name_to_id`
Example:
```
```json
{
"games": {
...
@@ -76,7 +84,10 @@ Example:
### `/datapackage/<string:checksum>`
<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:
- A checksum `checksum`
- 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`
<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.
Example:
```
```json
{
...
"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>
Submits a game to the WebHost for generation.
**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.
@@ -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))
Example using the python requests library:
```
```python
file = {'file': open('Games.zip', 'rb')}
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))
Example using the python requests library:
```
```python
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
weights={"weights": data}
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))
Example:
```
```json
{
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
"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`
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>`
<a name="status"></a>
Retrieves the status of the seed's generation.
This endpoint will return a dict with a single key-vlaue pair. The key will always be `text`
**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:
- Generation was completed: `Generation done` with a 201 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>`
<a name="roomstatus"></a>
**Cache timer: None**
Will provide a dict of room data with the following keys:
- Tracker SUUID (`tracker`)
- 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`)
- The room timeout counter (`timeout`)
- A list of downloads for files required for gameplay (`downloads`)
- Each item is a dict containings the download URL and slot (`slot`, `download`)
- Each item is a dict containing the download URL and slot (`slot`, `download`)
Example:
```
```json
{
"downloads": [
{
@@ -244,7 +263,7 @@ Example:
]
],
"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>`
<a name=tracker></a>
**Cache timer: 60 seconds**
Will provide a dict of tracker data with the following keys:
- Each player's current alias (`aliases`)
- Will return the name if there is none
- A list of items each player has received as a NetworkItem (`player_items_received`)
- A list of players current alias data (`aliases`)
- Each item containing a dict with, their alias `alias`, their player number `player`, and their team `team`
- `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`)
- The total number of checks done by all players (`total_checks_done`)
- Hints that players have used or received (`hints`)
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
- The current client status of each player (`player_status`)
- Each item containing a dict with, a list of checked location id's `locations`, their player number `player`, and their team `team`
- A list of the total number of checks done by all players (`total_checks_done`)
- Each item will contain a dict with, the total checks done `checks_done`, and the team `team`
- A list of [Hints](network%20protocol.md#hint) data that players have used or received (`hints`)
- 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:
```json
@@ -279,7 +308,12 @@ Example:
"team": 0,
"player": 2,
"alias": "Slot_Name_2"
}
},
{
"team": 0,
"player": 3,
"alias": null
},
],
"player_items_received": [
{
@@ -378,12 +412,18 @@ Example:
### `/static_tracker/<suuid:tracker>`
<a name=statictracker></a>
**Cache timer: 300 seconds**
Will provide a dict of static tracker data with the following keys:
- item_link groups and their players (`groups`)
- The datapackage hash for each game (`datapackage`)
- A list of item_link groups and their member players (`groups`)
- 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
- 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).
- The game each player is playing (`player_game`)
- Provided as a list of objects with `team`, `player`, and `game`.
@@ -446,7 +486,12 @@ Example:
### `/slot_data_tracker/<suuid:tracker>`
<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:
```json
@@ -474,6 +519,8 @@ User endpoints can get room and seed details from the current session tokens (co
### `/get_rooms`
<a name="getrooms"></a>
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:
- Room SUUID (`room_id`)
- Seed SUUID (`seed_id`)
@@ -484,25 +531,25 @@ Each list item will contain a dict with the room's details:
- Room tracker SUUID (`tracker`)
Example:
```
```json
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:53 GMT",
"last_activity": "Fri, 18 Apr 2025 21:16:02 GMT",
"last_port": 52122,
"room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a",
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6",
"room_id": "0D30FgQaRcWivFsw9o8qzw",
"seed_id": "TFjiarBgTsCj5-Jbe8u33A",
"timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
"tracker": "52BycvJhRe6knrYH8v4bag"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:42 GMT",
"last_activity": "Fri, 18 Apr 2025 20:36:46 GMT",
"last_port": 56884,
"room_id": "14465c05-d08e-4d28-96bd-916f994609d8",
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb",
"room_id": "LMCFchESSNyuqcY3GxkhwA",
"seed_id": "CENtJMXCTGmkIYCzjB5Csg",
"timeout": 7200,
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
"tracker": "2gVkMQgISGScA8wsvDZg5A"
}
]
```
@@ -510,6 +557,8 @@ Example:
### `/get_seeds`
<a name="getseeds"></a>
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:
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
@@ -517,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
Example:
```
```json
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
@@ -543,7 +592,7 @@ Example:
"Ocarina of Time"
]
],
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
"seed_id": "CENtJMXCTGmkIYCzjB5Csg"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
@@ -565,7 +614,7 @@ Example:
"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.
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`.
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being

View File

@@ -208,6 +208,11 @@ 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\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: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

View File

@@ -19,6 +19,7 @@ os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
os.environ["SDL_MOUSE_FOCUS_CLICKTHROUGH"] = "1"
import Utils

2
mypy.ini Normal file
View File

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

View File

@@ -13,5 +13,9 @@ cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
pathspec>=0.12.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on
Pymem>=1.13.0

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

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

View File

@@ -1,5 +1,6 @@
import unittest
from argparse import Namespace
from collections import ChainMap
from typing import Type
from BaseClasses import CollectionState, MultiWorld
@@ -82,12 +83,13 @@ class TestBase(unittest.TestCase):
def test_items_in_datapackage(self):
"""Test that any created items in the itempool are in the datapackage"""
archipelago = AutoWorldRegister.world_types["Archipelago"]
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type)
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:
"""
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.

View File

@@ -1,8 +1,9 @@
import unittest
from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -81,6 +82,19 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
for 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):
"""Test that plando options using containers of a custom type can be pickled"""

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,11 +1,22 @@
import logging
import os
from uuid import UUID, uuid4, uuid5
from flask import url_for
from WebHostLib.customserver import set_up_logging, tear_down_logging
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):
room_id: UUID
log_filename: str
@@ -39,7 +50,7 @@ class TestHostFakeRoom(TestBase):
try:
os.unlink(self.log_filename)
except FileNotFoundError:
except OSError:
pass
def test_display_log_missing_full(self) -> None:
@@ -191,3 +202,27 @@ class TestHostFakeRoom(TestBase):
with db_session:
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))
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)

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

@@ -5,17 +5,18 @@ import logging
import pathlib
import sys
import time
from collections.abc import Callable, Iterable, Mapping
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
from typing import (Any, ClassVar, Dict, FrozenSet, List, Optional, Self, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
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
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 settings import Group
@@ -47,27 +48,31 @@ class AutoWorldRegister(type):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
if "web" in dct:
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:
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"
# set minimum required_client_version from 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:
method = getattr(multiworld.worlds[player], method_name)
world = multiworld.worlds[player]
method = getattr(world, method_name)
try:
ret = _timed_call(method, *args, multiworld=multiworld, player=player)
except Exception as e:
@@ -184,6 +190,10 @@ def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args:
logging.error(message)
raise e
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
@@ -484,7 +494,14 @@ class World(metaclass=AutoWorldRegister):
raise NotImplementedError
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.")
return self.random.choice(tuple(self.item_name_to_id.keys()))
@@ -538,6 +555,10 @@ class World(metaclass=AutoWorldRegister):
return True
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.
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@@ -586,6 +607,64 @@ class World(metaclass=AutoWorldRegister):
res["checksum"] = data_package_checksum(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,
# please use a prefix as all of them get clobbered together

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
class Type(Enum):
@@ -247,7 +247,8 @@ components: List[Component] = [
# MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')),
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL),
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL,
description="Write item/location data for installed worlds to a file and open it."),
]
@@ -278,6 +279,10 @@ if not is_frozen():
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
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")
os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in games:
@@ -305,18 +310,17 @@ if not is_frozen():
apworld = APWorldContainer(str(zip_path))
apworld.game = worldtype.game
manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for path in pathlib.Path(world_directory).rglob("*"):
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:
continue
if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path)
apworld.manifest_path = os.path.join(file_name, "archipelago.json")
local_ignores = read_apignore(pathlib.Path(world_directory, ".apignore"))
apignores = global_apignores + local_ignores if local_ignores else global_apignores
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for file in apignores.match_tree_files(world_directory, negate=True):
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder)
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -1,17 +1,20 @@
import importlib
import importlib.util
import importlib.abc
import importlib.machinery
import logging
import os
import sys
import warnings
import zipimport
import time
import dataclasses
import json
from typing import List
from pathlib import Path
from types import ModuleType
from typing import List, Sequence
from zipfile import BadZipFile
from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
from Utils import local_path, user_path, Version, version_tuple, tuplize_version, messagebox
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -20,14 +23,14 @@ try:
except OSError: # can't access/write?
user_folder = None
__all__ = {
__all__ = [
"network_data_package",
"AutoWorldRegister",
"world_sources",
"local_folder",
"user_folder",
"failed_world_loads",
}
]
failed_world_loads: List[str] = []
@@ -53,21 +56,7 @@ class WorldSource:
def load(self) -> bool:
try:
start = time.perf_counter()
if self.is_zip:
importer = zipimport.zipimporter(self.resolved_path)
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
assert spec, f"{self.path} is not a loadable module"
mod = importlib.util.module_from_spec(spec)
mod.__package__ = f"worlds.{mod.__package__}"
mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
importer.exec_module(mod)
else:
importlib.import_module(f".{self.path}", "worlds")
importlib.import_module(f".{Path(self.path).stem}", "worlds")
self.time_taken = time.perf_counter()-start
return True
@@ -112,7 +101,6 @@ for world_source in world_sources:
else:
world_source.load()
from .AutoWorld import AutoWorldRegister
for world_source in world_sources:
@@ -157,6 +145,15 @@ if apworlds:
logging.error(e)
else:
raise e
except BadZipFile as e:
err_message = (f"The world source {apworld_source.resolved_path} is not a valid zip. "
"It is likely either corrupted, or was packaged incorrectly.")
if sys.stdout:
raise RuntimeError(err_message) from e
else:
messagebox("Couldn't load worlds", err_message, error=True)
sys.exit(1)
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
fail_world(apworld.game,
@@ -174,6 +171,16 @@ if apworlds:
core_compatible.sort(
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
reverse=True)
apworld_module_specs = {}
class APWorldModuleFinder(importlib.abc.MetaPathFinder):
def find_spec(
self, fullname: str, _path: Sequence[str] | None, _target: ModuleType = None
) -> importlib.machinery.ModuleSpec | None:
return apworld_module_specs.get(fullname)
sys.meta_path.insert(0, APWorldModuleFinder())
for apworld_source, apworld in core_compatible:
if apworld.game and apworld.game in AutoWorldRegister.world_types:
fail_world(apworld.game,
@@ -181,6 +188,12 @@ if apworlds:
f"as its game {apworld.game} is already loaded.",
add_as_failed_to_load=False)
else:
importer = zipimport.zipimporter(apworld_source.resolved_path)
world_name = Path(apworld.path).stem
spec = importer.find_spec(f"worlds.{world_name}")
apworld_module_specs[f"worlds.{world_name}"] = spec
apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
# world could fail to load at this point

View File

@@ -2,4 +2,4 @@ mpyq>=0.2.5
portpicker>=1.5.2
aiohttp>=3.8.4
loguru>=0.7.0
protobuf==6.31.1
protobuf==6.33.5

View File

@@ -63,6 +63,9 @@ def is_location_valid(world: "HatInTimeWorld", location: str) -> bool:
if not world.options.ShuffleStorybookPages and location in storybook_pages.keys():
return False
if not world.options.ShuffleDirectorTokens and location in director_tokens.keys():
return False
if not world.options.ShuffleActContracts and location in contract_locations.keys():
return False
@@ -566,6 +569,34 @@ storybook_pages = {
"Rumbi Factory - Page: Last Area": LocData(2000345883, "Time Rift - Rumbi Factory", dlc_flags=HatDLC.dlc2),
}
director_tokens = {
"Murder on the Owl Express - Conductor Token: Cafeteria": LocData(2001104767, "Murder on the Owl Express"),
"Murder on the Owl Express - Conductor Token: Recreational Room": LocData(2001104768, "Murder on the Owl Express"),
"Picture Perfect - DJ Grooves Token: Cardboard Puppy": LocData(2001203990, "Picture Perfect"),
"Picture Perfect - DJ Grooves Token: Card Guessing Game": LocData(2001203991, "Picture Perfect"),
"Picture Perfect - DJ Grooves Token: Back Alley": LocData(2001203992, "Picture Perfect"),
"Picture Perfect - DJ Grooves Token: Cooking Show": LocData(2001203993, "Picture Perfect"),
"Picture Perfect - DJ Grooves Token: Pon Cluster": LocData(2001203987, "Picture Perfect"),
"Train Rush - Time Bonus: 1st Room": LocData(2001305235, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Falling Platform": LocData(2001305189, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Acid Crates": LocData(2001305186, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Balloon": LocData(2001305239, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Ring of Fire": LocData(2001305237, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Blue Panels": LocData(2001305236, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Sinking Lava Platform": LocData(2001305234, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Lava Panels 1": LocData(2001305193, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Lava Panels 2": LocData(2001305190, "Train Rush", hookshot=True),
"Train Rush - Time Bonus: Lava Panels 3": LocData(2001305238, "Train Rush", hookshot=True),
"The Big Parade - DJ Grooves Token (1/8)": LocData(2001400000, "The Big Parade"),
"The Big Parade - DJ Grooves Token (2/8)": LocData(2001400001, "The Big Parade"),
"The Big Parade - DJ Grooves Token (3/8)": LocData(2001400002, "The Big Parade"),
"The Big Parade - DJ Grooves Token (4/8)": LocData(2001400003, "The Big Parade"),
"The Big Parade - DJ Grooves Token (5/8)": LocData(2001400004, "The Big Parade", hit_type=HitType.umbrella),
"The Big Parade - DJ Grooves Token (6/8)": LocData(2001400005, "The Big Parade", hit_type=HitType.umbrella),
"The Big Parade - DJ Grooves Token (7/8)": LocData(2001400006, "The Big Parade", hit_type=HitType.umbrella),
"The Big Parade - DJ Grooves Token (8/8)": LocData(2001400007, "The Big Parade", hit_type=HitType.umbrella),
}
shop_locations = {
"Badge Seller - Item 1": LocData(2000301003, "Badge Seller"),
"Badge Seller - Item 2": LocData(2000301004, "Badge Seller"),
@@ -1050,6 +1081,7 @@ location_table = {
**ahit_locations,
**act_completions,
**storybook_pages,
**director_tokens,
**contract_locations,
**shop_locations,
}

View File

@@ -72,6 +72,7 @@ def adjust_options(world: "HatInTimeWorld"):
world.options.EndGoal.value = EndGoal.option_seal_the_deal
world.options.ActRandomizer.value = 0
world.options.ShuffleAlpineZiplines.value = 0
world.options.ShuffleDirectorTokens.value = 0
world.options.ShuffleSubconPaintings.value = 0
world.options.ShuffleStorybookPages.value = 0
world.options.ShuffleActContracts.value = 0
@@ -219,6 +220,12 @@ class ShuffleStorybookPages(DefaultOnToggle):
display_name = "Shuffle Storybook Pages"
class ShuffleDirectorTokens(Toggle):
"""If enabled, causes the Conductor/DJ Grooves tokens found in Chapter 2 levels to become item checks.
NOTE: This also includes the time bonus pickups from Train Rush, since the level doesn't have any tokens."""
display_name = "Shuffle Director Tokens"
class ShuffleActContracts(DefaultOnToggle):
"""If enabled, shuffle Snatcher's act contracts into the pool as items"""
display_name = "Shuffle Contracts"
@@ -658,6 +665,7 @@ class AHITOptions(PerGameCommonOptions):
StartWithCompassBadge: StartWithCompassBadge
CompassBadgeMode: CompassBadgeMode
ShuffleStorybookPages: ShuffleStorybookPages
ShuffleDirectorTokens: ShuffleDirectorTokens
ShuffleActContracts: ShuffleActContracts
ShuffleSubconPaintings: ShuffleSubconPaintings
NoPaintingSkips: NoPaintingSkips
@@ -722,7 +730,8 @@ class AHITOptions(PerGameCommonOptions):
ahit_option_groups: Dict[str, List[Any]] = {
"General Options": [EndGoal, ShuffleStorybookPages, ShuffleAlpineZiplines, ShuffleSubconPaintings,
"General Options": [EndGoal, ShuffleStorybookPages, ShuffleDirectorTokens,
ShuffleAlpineZiplines, ShuffleSubconPaintings,
ShuffleActContracts, MinPonCost, MaxPonCost, BadgeSellerMinItems, BadgeSellerMaxItems,
LogicDifficulty, NoPaintingSkips, CTRLogic],
@@ -759,6 +768,7 @@ slot_data_options: List[str] = [
"StartWithCompassBadge",
"CompassBadgeMode",
"ShuffleStorybookPages",
"ShuffleDirectorTokens",
"ShuffleActContracts",
"ShuffleSubconPaintings",
"NoPaintingSkips",

View File

@@ -1,7 +1,7 @@
from BaseClasses import Region, Entrance, ItemClassification, Location, LocationProgressType
from .Types import ChapterIndex, Difficulty, HatInTimeLocation, HatInTimeItem
from .Locations import location_table, storybook_pages, event_locs, is_location_valid, \
shop_locations, TASKSANITY_START_ID, snatcher_coins, zero_jumps, zero_jumps_expert, zero_jumps_hard
shop_locations, TASKSANITY_START_ID, snatcher_coins, zero_jumps, zero_jumps_expert, zero_jumps_hard, director_tokens
from typing import TYPE_CHECKING, List, Dict, Optional
from .Rules import set_rift_rules, get_difficulty
from .Options import ActRandomizer, EndGoal
@@ -859,6 +859,9 @@ def create_region(world: "HatInTimeWorld", name: str) -> Region:
if key in storybook_pages.keys() and not world.options.ShuffleStorybookPages:
continue
if key in director_tokens.keys() and not world.options.ShuffleDirectorTokens:
continue
location = HatInTimeLocation(world.player, key, data.id, reg)
reg.locations.append(location)
if location.name in shop_locations:

View File

@@ -16,10 +16,6 @@ def make_data_directory(dir_name: str) -> Path:
gitignore = specific_data_directory / ".gitignore"
with open(gitignore, "w") as f:
f.write(
"""*
!.gitignore
"""
)
f.write("*\n")
return specific_data_directory

View File

@@ -31,3 +31,21 @@ components.append(
supports_uri=True,
)
)
# There are two optional parameters that are worth drawing attention to here: "game_name" and "supports_uri".
# As you might know, on a room page on WebHost, clicking a slot name opens your locally installed Launcher
# and asks you if you want to open a Text Client.
# If you have "game_name" set on your Component, your user also gets the option to open that instead.
# Furthermore, if you have "supports_uri" set to True, your Component will be passed a uri as an arg.
# This uri contains the room url + port, the slot name, and the password.
# You can process this uri arg to automatically connect the user to their slot without having to type anything.
# As you can see above, the APQuest client has both of these parameters set.
# This means a user can click on the slot name of an APQuest slot on WebHost,
# then click "APQuest Client" instead of "Text Client" in the Launcher popup, and after a few seconds,
# they will be connected and playing the game without having to touch their keyboard once.
# Since a Component is just Python code, this doesn't just work with CommonClient-derived clients.
# You could forward this uri arg to your standalone C++/Java/.NET/whatever client as well,
# meaning just about every client can support this "Click on slot name -> Everything happens automatically" action.
# The author would like to see more clients be aware of this feature and try to support it.

View File

@@ -158,11 +158,11 @@ class Game:
if not self.gameboard.ready:
return
if self.active_math_problem is not None:
if input_key in DIGIT_INPUTS_TO_DIGITS:
self.math_problem_input(DIGIT_INPUTS_TO_DIGITS[input_key])
if input_key == Input.BACKSPACE:
self.math_problem_delete()
if input_key in DIGIT_INPUTS_TO_DIGITS:
self.math_problem_input(DIGIT_INPUTS_TO_DIGITS[input_key])
return
if input_key == Input.BACKSPACE:
self.math_problem_delete()
return
if input_key == Input.LEFT:

View File

@@ -5,7 +5,8 @@ from typing import Any
from worlds.AutoWorld import World
# Imports of your world's files must be relative.
from . import items, locations, options, regions, rules, web_world
from . import items, locations, regions, rules, web_world
from . import options as apquest_options # rename due to a name conflict with World.options
# APQuest will go through all the parts of the world api one step at a time,
# with many examples and comments across multiple files.
@@ -36,8 +37,9 @@ class APQuestWorld(World):
web = web_world.APQuestWebWorld()
# This is how we associate the options defined in our options.py with our world.
options_dataclass = options.APQuestOptions
options: options.APQuestOptions # Common mistake: This has to be a colon (:), not an equals sign (=).
# (Note: options.py has been imported as "apquest_options" at the top of this file to avoid a name conflict)
options_dataclass = apquest_options.APQuestOptions
options: apquest_options.APQuestOptions # Common mistake: This has to be a colon (:), not an equals sign (=).
# Our world class must have a static location_name_to_id and item_name_to_id defined.
# We define these in regions.py and items.py respectively, so we just set them here.

View File

@@ -29,6 +29,7 @@ class ItemGroup(Enum):
UTILITY = 4
SONG = 5
TURTLE = 6
DOOR = 7
class AquariaItem(Item):
@@ -211,6 +212,7 @@ class ItemNames:
TRANSTURTLE_BODY = "Transturtle Final Boss"
TRANSTURTLE_SIMON_SAYS = "Transturtle Simon Says"
TRANSTURTLE_ARNASSI_RUINS = "Transturtle Arnassi Ruins"
DOOR_TO_CATHEDRAL = "Door to the Cathedral opened"
# Events name
BODY_TONGUE_CLEARED = "Body Tongue cleared"
HAS_SUN_CRYSTAL = "Has Sun Crystal"
@@ -240,7 +242,7 @@ item_table = {
ItemNames.BIG_SEED: ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed
ItemNames.GLOWING_SEED: ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed
ItemNames.BLACK_PEARL: ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl
ItemNames.BABY_BLASTER: ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster
ItemNames.BABY_BLASTER: ItemData(698005, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_blaster
ItemNames.CRAB_ARMOR: ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume
ItemNames.BABY_DUMBO: ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo
ItemNames.TOOTH: ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss
@@ -256,8 +258,8 @@ item_table = {
ItemNames.MITHALAS_BANNER: ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner
ItemNames.MITHALAS_POT: ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot
ItemNames.MUTANT_COSTUME: ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume
ItemNames.BABY_NAUTILUS: ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus
ItemNames.BABY_PIRANHA: ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha
ItemNames.BABY_NAUTILUS: ItemData(698021, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_nautilus
ItemNames.BABY_PIRANHA: ItemData(698022, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_piranha
ItemNames.ARNASSI_ARMOR: ItemData(698023, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_seahorse_costume
ItemNames.SEED_BAG: ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag
ItemNames.KING_S_SKULL: ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull
@@ -371,4 +373,20 @@ item_table = {
ItemNames.TRANSTURTLE_BODY: ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss
ItemNames.TRANSTURTLE_SIMON_SAYS: ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05
ItemNames.TRANSTURTLE_ARNASSI_RUINS: ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse
ItemNames.DOOR_TO_CATHEDRAL: ItemData(698134, 1, ItemType.PROGRESSION, ItemGroup.DOOR), # door_to_cathedral
}
four_gods_excludes = [ItemNames.ANEMONE, ItemNames.ARNASSI_STATUE, ItemNames.BIG_SEED, ItemNames.GLOWING_SEED,
ItemNames.BLACK_PEARL, ItemNames.TOOTH, ItemNames.ENERGY_STATUE, ItemNames.KROTITE_ARMOR,
ItemNames.GOLDEN_STARFISH, ItemNames.GOLDEN_GEAR, ItemNames.JELLY_BEACON,
ItemNames.JELLY_PLANT, ItemNames.MITHALAS_DOLL, ItemNames.MITHALAN_DRESS,
ItemNames.MITHALAS_BANNER, ItemNames.MITHALAS_POT, ItemNames.MUTANT_COSTUME, ItemNames.SEED_BAG,
ItemNames.KING_S_SKULL, ItemNames.SONG_PLANT_SPORE, ItemNames.STONE_HEAD, ItemNames.SUN_KEY,
ItemNames.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG,
ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM,
ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE,
ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE,
ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT,
ItemNames.SEA_LOAF_X_2, ItemNames.SMALL_EGG]

View File

@@ -233,7 +233,7 @@ class AquariaLocationNames:
SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM = "Sun Temple, bulb at the top of the high dark room"
SUN_TEMPLE_GOLDEN_GEAR = "Sun Temple, Golden Gear"
SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE = "Sun Temple, first bulb of the temple"
SUN_TEMPLE_BULB_ON_THE_RIGHT_PART = "Sun Temple, bulb on the right part"
SUN_TEMPLE_BULB_ON_THE_RIGHT_PART = "Sun Temple, bulb in the right part"
SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART = "Sun Temple, bulb in the hidden room of the right part"
SUN_TEMPLE_SUN_KEY = "Sun Temple, Sun Key"
SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB = "Sun Temple boss path, first path bulb"
@@ -306,6 +306,7 @@ class AquariaLocationNames:
BEATING_CRABBIUS_MAXIMUS = "Beating Crabbius Maximus"
BEATING_MANTIS_SHRIMP_PRIME = "Beating Mantis Shrimp Prime"
BEATING_KING_JELLYFISH_GOD_PRIME = "Beating King Jellyfish God Prime"
SITTING_ON_THRONE = "Mithalas City Castle, sitting on the sealed throne"
FIRST_SECRET = "First Secret"
SECOND_SECRET = "Second Secret"
THIRD_SECRET = "Third Secret"
@@ -497,6 +498,7 @@ class AquariaLocations:
locations_mithalas_castle = {
AquariaLocationNames.MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE: 698042,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BLUE_BANNER: 698165,
AquariaLocationNames.SITTING_ON_THRONE: 698218,
}
locations_mithalas_castle_urns = {
@@ -803,6 +805,10 @@ class AquariaLocations:
AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE: 698215,
}
locations_final_boss_tube_transturtle_only = {
AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE: 698215,
}
locations_final_boss = {
AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM: 698106,
}

View File

@@ -11,9 +11,11 @@ from Options import Toggle, Choice, Range, PerGameCommonOptions, DefaultOnToggle
class IngredientRandomizer(Choice):
"""
Select if the simple ingredients (that do not have a recipe) should be randomized.
If "Common Ingredients" is selected, the randomization will exclude the "Red Bulb", "Special Bulb" and "Rukh Egg".
"""
display_name = "Randomize Ingredients"
rich_text_doc = True
option_off = 0
alias_false = 0
option_common_ingredients = 1
@@ -26,11 +28,17 @@ class IngredientRandomizer(Choice):
class DishRandomizer(Toggle):
"""Randomize the drop of Dishes (Ingredients with recipe)."""
display_name = "Dish Randomizer"
rich_text_doc = True
class TurtleRandomizer(Choice):
"""Randomize the transportation turtle."""
"""
Randomize the transportation turtle.
If the objective is "killing the four gods" or "Gods and Creator", the abyss and body turtle will not be randomized.
"""
display_name = "Turtle Randomizer"
rich_text_doc = True
option_none = 0
alias_off = 0
alias_false = 0
@@ -47,6 +55,7 @@ class EarlyBindSong(Choice):
selected).
"""
display_name = "Early Bind song"
rich_text_doc = True
option_off = 0
alias_false = 0
option_early = 1
@@ -62,6 +71,7 @@ class EarlyEnergyForm(Choice):
selected).
"""
display_name = "Early Energy form"
rich_text_doc = True
option_off = 0
alias_false = 0
option_early = 1
@@ -74,14 +84,19 @@ class EarlyEnergyForm(Choice):
class AquarianTranslation(Toggle):
"""Translate the Aquarian scripture in the game into English."""
display_name = "Translate Aquarian"
rich_text_doc = True
class BigBossesToBeat(Range):
"""
The number of big bosses to beat before having access to the creator (the final boss). The big bosses are
"Fallen God", "Mithalan God", "Drunian God", "Lumerean God" and "The Golem".
The number of big bosses to beat before having access to the creator (the final boss).
The big bosses are "Fallen God", "Mithalan God", "Drunian God", "Lumerean God" and "The Golem".
Has no effect if the objective is "killing the four gods" or "Gods and Creator".
"""
display_name = "Big bosses to beat"
rich_text_doc = True
range_start = 0
range_end = 5
default = 0
@@ -89,12 +104,18 @@ class BigBossesToBeat(Range):
class MiniBossesToBeat(Range):
"""
The number of minibosses to beat before having access to the creator (the final boss). The minibosses are
"Nautilus Prime", "Blaster Peg Prime", "Mergog", "Mithalan priests", "Octopus Prime", "Crabbius Maximus",
"Mantis Shrimp Prime" and "King Jellyfish God Prime".
Note that the Energy Statue and Simon Says are not minibosses.
The number of minibosses to beat before having access to the goal.
The minibosses are "Nautilus Prime", "Blaster Peg Prime", "Mergog", "Mithalan priests", "Octopus Prime",
"Crabbius Maximus", "Mantis Shrimp Prime" and "King Jellyfish God Prime".
Note that the "Energy Statue" and "Simon Says" are not minibosses.
Also note that if the objective is "killing the four enemy gods" or "Gods and creator", it might be needed to go in the abyss and
bubble cave to kill "King Jellyfish God Prime" and "Mantis Shrimp Prime".
"""
display_name = "Minibosses to beat"
rich_text_doc = True
range_start = 0
range_end = 8
default = 0
@@ -102,38 +123,48 @@ class MiniBossesToBeat(Range):
class Objective(Choice):
"""
The game objective can be to kill the creator or to kill the creator after obtaining all three secret memories.
**Kill the Creator:** Get to the final boss (the Creator) and beat all it's forms.
**Obtain secrets and kill the Creator:** like the "Kill the Creator", but need to find all three secret memories
before getting to the Creator.
**Killing the four gods:**, Beat all four enemy gods ("Fallen God", "Mithalan God", "Drunian God", "Lumerean God").
**Gods and Creator:** like "Killing the four gods" but you also have to beat the creator.
"""
display_name = "Objective"
rich_text_doc = True
option_kill_the_creator = 0
option_obtain_secrets_and_kill_the_creator = 1
option_killing_the_four_gods = 2
option_gods_and_creator = 3
default = 0
class SkipFirstVision(Toggle):
"""
The first vision in the game, where Naija transforms into Energy Form and gets flooded by enemies, is quite cool but
can be quite long when you already know what is going on. This option can be used to skip this vision.
Skip the first vision in the game, where Naija transforms into Energy Form and gets flooded by enemies.
"""
display_name = "Skip Naija's first vision"
rich_text_doc = True
class NoProgressionHardOrHiddenLocation(Toggle):
"""
Make sure that there are no progression items at hard-to-reach or hard-to-find locations.
Those locations are very High locations (that need beast form, soup and skill to get),
every location in the bubble cave, locations where need you to cross a false wall without any indication,
the Arnassi race, bosses and minibosses. Useful for those that want a more casual run.
"""
display_name = "No progression in hard or hidden locations"
class LightNeededToGetToDarkPlaces(DefaultOnToggle):
class LightNeededToGetToDarkPlaces(Choice):
"""
Make sure that the sun form or the dumbo pet can be acquired before getting to dark places.
Be aware that navigating in dark places without light is extremely difficult.
You can also force the sun form to be accessible by using the "sun form" option.
"""
display_name = "Light needed to get to dark places"
rich_text_doc = True
option_off = 0
alias_false = 0
option_on = 1
alias_true = 1
option_sun_form = 2
default = 1
class BindSongNeededToGetUnderRockBulb(DefaultOnToggle):
@@ -141,23 +172,83 @@ class BindSongNeededToGetUnderRockBulb(DefaultOnToggle):
Make sure that the bind song can be acquired before having to obtain sing bulbs under rocks.
"""
display_name = "Bind song needed to get sing bulbs under rocks"
rich_text_doc = True
class BlindGoal(Toggle):
"""
Hide the goal's requirements from the help page so that you have to go to the last boss door to know
what is needed to access the boss.
Hide the goal's requirements from the help page so that you don't know what is needed to goal.
Note that when you get to the final boss door (or you beat the last gods when the "Killing the four gods"
is selected) you can then see the requirements in the help page.
"""
display_name = "Hide the goal's requirements"
rich_text_doc = True
class InfiniteHotSoup(DefaultOnToggle):
"""
As soon as a "hot soup" is received, the user will never run out of this dish.
This option is recommended if using Ingredient randomization since "hot soup" ingredients may become hard to get
and the "hot soup" is necessary to get to some locations.
"""
display_name = "Infinite Hot Soup"
rich_text_doc = True
class SaveHealing(DefaultOnToggle):
"""
When you save, Naija is healed back to full health. If disabled, saving won't heal Naija.
Note that Naija can still heal by sleeping in some beds in the game (including in her home).
"""
display_name = "Save heal Naija"
rich_text_doc = True
class OpenBodyTongue(Toggle):
"""
Remove the body tongue making the body accessible without going in the sunken city
"""
display_name = "Open the body tongue"
rich_text_doc = True
class SkipFinalBoss3rdForm(Toggle):
"""
The Final boss third form (the hide and seek form) can be easy and quite long. So, this option can be used
to skip this form.
Note that you will still need to deliver the final blow to the 3rd form in order to activate the 4th form animation.
"""
display_name = "Skip final boss third form"
class MaximumIngredientAmount(Range):
"""
The maximum number of the same ingredients that can be stacked on the ingredient inventory.
"""
display_name = "Maximum ingredient amount"
rich_text_doc = True
range_start = 2
range_end = 20
default = 8
class UnconfineHomeWater(Choice):
"""
Open the way out of the Home Waters area so that Naija can go to open water and beyond without the bind song.
**Via energy door:** Open the energy door between the home waters and the open waters
**Via transturtle:** Remove the rock blocking the home water transturtle.
Note that if you turn this option off, it is recommended to turn on the Early Energy form and Early Bind Song
options.
"""
display_name = "Unconfine Home Waters Area"
rich_text_doc = True
option_off = 0
alias_false = 0
option_via_energy_door = 1
@@ -168,6 +259,134 @@ class UnconfineHomeWater(Choice):
default = 0
class ThroneAsLocation(Toggle):
"""
If enabled, sitting on the Mithalas City Castle throne (with the seal on it) will be a location and opening the
door to the Mithalas Cathedral will be an item.
"""
display_name = "Throne as a location"
rich_text_doc = True
class NoProgressionHardOrHiddenLocation(Toggle):
"""
Make sure that there are no progression items at hard-to-reach or hard-to-find locations.
Those locations are very High locations (that need beast form, soup and skill to get), every location in the
bubble cave, locations where need you to cross a false wall without any indication, the Arnassi race,
bosses and minibosses.
Useful for those that want a more casual run.
"""
display_name = "No progression in hard or hidden locations"
rich_text_doc = True
class NoProgressionSimonSays(Toggle):
"""
Make sure that there are no progression items in the says area.
"""
display_name = "No progression in Simon says area"
rich_text_doc = True
class NoProgressionKelpForest(Toggle):
"""
Make sure that there are no progression items in Kelp Forest (excluding Simon says area).
Can be useful to get smaller runs.
"""
display_name = "No progression in Kelp Forest"
class NoProgressionVeil(Toggle):
"""
Make sure that there are no progression items in the Veil.
Can be useful to get smaller runs.
"""
display_name = "No progression in the Veil"
rich_text_doc = True
class NoProgressionMithalas(Toggle):
"""
Make sure that there are no progression items in the Mithalas (city, castle and cathedral).
Can be useful to get smaller runs.
"""
display_name = "No progression in Mithalas"
rich_text_doc = True
class NoProgressionEnergyTemple(Toggle):
"""
Make sure that there are no progression items in the Energy Temple.
Can be useful to get smaller runs.
"""
display_name = "No progression in the Energy Temple"
rich_text_doc = True
class NoProgressionArnassiRuins(Toggle):
"""
Make sure that there are no progression items in the Arnassi Ruins.
Can be useful to get smaller runs.
Note that if the Transportation turtle are not randomize, this include Simon Says area.
"""
display_name = "No progression in Arnassi Ruins"
rich_text_doc = True
class NoProgressionFrozenVeil(Toggle):
"""
Make sure that there are no progression items in the Frozen Veil (including Ice Cavern and Bubble Cave).
Can be useful to get smaller runs.
"""
display_name = "No progression in the Frozen Veil"
rich_text_doc = True
class NoProgressionAbyss(Toggle):
"""
Make sure that there are no progression items in the Abyss.
Can be useful to get smaller runs.
Has no effect if the objective is "killing the four gods".
"""
display_name = "No progression in the Abyss"
rich_text_doc = True
class NoProgressionSunkenCity(Toggle):
"""
Make sure that there are no progression items in the Sunken City.
Can be useful to get smaller runs.
Has no effect if the objective is "killing the four gods".
"""
display_name = "No progression in the Sunken City"
rich_text_doc = True
class NoProgressionBody(Toggle):
"""
Make sure that there are no progression items in the Body (including the before-boss transturtle room
and the boss location).
Can be useful to get smaller runs.
Has no effect if the objective is "killing the four gods".
"""
display_name = "No progression in the Body"
rich_text_doc = True
@dataclass
class AquariaOptions(PerGameCommonOptions):
"""
@@ -183,9 +402,25 @@ class AquariaOptions(PerGameCommonOptions):
light_needed_to_get_to_dark_places: LightNeededToGetToDarkPlaces
bind_song_needed_to_get_under_rock_bulb: BindSongNeededToGetUnderRockBulb
unconfine_home_water: UnconfineHomeWater
no_progression_hard_or_hidden_locations: NoProgressionHardOrHiddenLocation
ingredient_randomizer: IngredientRandomizer
dish_randomizer: DishRandomizer
aquarian_translation: AquarianTranslation
skip_first_vision: SkipFirstVision
blind_goal: BlindGoal
infinite_hot_soup: InfiniteHotSoup
open_body_tongue: OpenBodyTongue
maximum_ingredient_amount: MaximumIngredientAmount
skip_final_boss_3rd_form: SkipFinalBoss3rdForm
save_healing: SaveHealing
throne_as_location: ThroneAsLocation
no_progression_hard_or_hidden_locations: NoProgressionHardOrHiddenLocation
no_progression_simon_says: NoProgressionSimonSays
no_progression_kelp_forest: NoProgressionKelpForest
no_progression_veil: NoProgressionVeil
no_progression_mithalas: NoProgressionMithalas
no_progression_energy_temple: NoProgressionEnergyTemple
no_progression_arnassi_ruins: NoProgressionArnassiRuins
no_progression_frozen_veil: NoProgressionFrozenVeil
no_progression_abyss: NoProgressionAbyss
no_progression_sunken_city: NoProgressionSunkenCity
no_progression_body: NoProgressionBody

View File

@@ -8,7 +8,7 @@ from typing import Dict, Optional, Iterable
from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState
from .Items import AquariaItem, ItemNames
from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames
from .Options import AquariaOptions, UnconfineHomeWater
from .Options import AquariaOptions, UnconfineHomeWater, LightNeededToGetToDarkPlaces, Objective
from worlds.generic.Rules import add_rule, set_rule
@@ -116,14 +116,27 @@ def _has_big_bosses(state: CollectionState, player: int) -> bool:
ItemNames.LUMEREAN_GOD_BEATED, ItemNames.THE_GOLEM_BEATED}, player)
def _has_mini_bosses(state: CollectionState, player: int) -> bool:
def _has_four_gods_beated(state: CollectionState, player: int) -> bool:
"""`player` in `state` has beated every big bosses"""
return state.has_all({ItemNames.FALLEN_GOD_BEATED, ItemNames.MITHALAN_GOD_BEATED, ItemNames.DRUNIAN_GOD_BEATED,
ItemNames.LUMEREAN_GOD_BEATED}, player)
def _has_mini_bosses(state: CollectionState, player: int) -> bool:
"""`player` in `state` has beated every mini bosses"""
return state.has_all({ItemNames.NAUTILUS_PRIME_BEATED, ItemNames.BLASTER_PEG_PRIME_BEATED, ItemNames.MERGOG_BEATED,
ItemNames.MITHALAN_PRIESTS_BEATED, ItemNames.OCTOPUS_PRIME_BEATED,
ItemNames.CRABBIUS_MAXIMUS_BEATED, ItemNames.MANTIS_SHRIMP_PRIME_BEATED,
ItemNames.KING_JELLYFISH_GOD_PRIME_BEATED}, player)
def _has_mini_bosses_four_gods(state: CollectionState, player: int) -> bool:
"""`player` in `state` has beated every mini bosses other than the ones in the abyss and in the bubble cave"""
return state.has_all({ItemNames.NAUTILUS_PRIME_BEATED, ItemNames.BLASTER_PEG_PRIME_BEATED, ItemNames.MERGOG_BEATED,
ItemNames.MITHALAN_PRIESTS_BEATED, ItemNames.OCTOPUS_PRIME_BEATED,
ItemNames.CRABBIUS_MAXIMUS_BEATED}, player)
def _has_secrets(state: CollectionState, player: int) -> bool:
"""The secrets have been acquired in the `state` of the `player`"""
return state.has_all({ItemNames.FIRST_SECRET_OBTAINED, ItemNames.SECOND_SECRET_OBTAINED,
@@ -133,6 +146,11 @@ def _item_not_advancement(item: Item):
"""The `item` is not an advancement item"""
return not item.advancement
def _is_cathedral_door_opened(state: CollectionState, player: int) -> bool:
"""The door to Mithalas Cathedral has been opened in the `state` of the `player`"""
return state.has(ItemNames.DOOR_TO_CATHEDRAL, player)
class AquariaRegions:
"""
Class used to create regions of the Aquaria game
@@ -236,10 +254,11 @@ class AquariaRegions:
body_rt: Region
body_rb: Region
body_b: Region
final_boss_loby: Region
final_boss_lobby: Region
final_boss_tube: Region
final_boss: Region
final_boss_end: Region
four_gods_end: Region
"""
Every Region of the game
"""
@@ -254,6 +273,11 @@ class AquariaRegions:
The ID of the player
"""
is_four_gods: bool
"""
True if the player has an objective that implies killing the four gods
"""
def __add_region(self, hint: str,
locations: Optional[Dict[str, int]]) -> Region:
"""
@@ -439,65 +463,82 @@ class AquariaRegions:
self.sun_temple_boss = self.__add_region("Sun Temple boss area",
AquariaLocations.locations_sun_temple_boss)
def __create_abyss(self) -> None:
def __create_abyss(self, add_locations: bool) -> None:
"""
Create the `abyss_*`, `ice_cave`, `king_jellyfish_cave` and `whale`
regions
"""
self.abyss_l = self.__add_region("Abyss left area",
AquariaLocations.locations_abyss_l)
self.abyss_lb = self.__add_region("Abyss left bottom area", AquariaLocations.locations_abyss_lb)
self.abyss_r = self.__add_region("Abyss right area", AquariaLocations.locations_abyss_r)
AquariaLocations.locations_abyss_l if add_locations else None)
self.abyss_lb = self.__add_region("Abyss left bottom area",
AquariaLocations.locations_abyss_lb if add_locations else None)
self.abyss_r = self.__add_region("Abyss right area",
AquariaLocations.locations_abyss_r if add_locations else None)
self.abyss_r_transturtle = self.__add_region("Abyss right area, transturtle",
AquariaLocations.locations_abyss_r_transturtle)
self.abyss_r_whale = self.__add_region("Abyss right area, outside the whale",
AquariaLocations.locations_abyss_r_whale)
self.ice_cave = self.__add_region("Ice Cavern", AquariaLocations.locations_ice_cave)
AquariaLocations.locations_abyss_r_whale if add_locations else None)
self.ice_cave = self.__add_region("Ice Cavern",
AquariaLocations.locations_ice_cave if add_locations else None)
self.frozen_feil = self.__add_region("Frozen Veil", None)
self.bubble_cave = self.__add_region("Bubble Cave", AquariaLocations.locations_bubble_cave)
self.bubble_cave_boss = self.__add_region("Bubble Cave boss area", AquariaLocations.locations_bubble_cave_boss)
self.bubble_cave = self.__add_region("Bubble Cave",
AquariaLocations.locations_bubble_cave if add_locations else None)
self.bubble_cave_boss = self.__add_region("Bubble Cave boss area",
AquariaLocations.locations_bubble_cave_boss
if add_locations else None)
self.king_jellyfish_cave = self.__add_region("Abyss left area, King jellyfish cave",
AquariaLocations.locations_king_jellyfish_cave)
self.whale = self.__add_region("Inside the whale", AquariaLocations.locations_whale)
AquariaLocations.locations_king_jellyfish_cave
if add_locations else None)
self.whale = self.__add_region("Inside the whale",
AquariaLocations.locations_whale if add_locations else None)
self.first_secret = self.__add_region("First Secret area", None)
def __create_sunken_city(self) -> None:
def __create_sunken_city(self, add_locations: bool) -> None:
"""
Create the `sunken_city_*` regions
"""
self.sunken_city_l = self.__add_region("Sunken City left area", None)
self.sunken_city_l_crates = self.__add_region("Sunken City left area",
AquariaLocations.locations_sunken_city_l)
AquariaLocations.locations_sunken_city_l
if add_locations else None)
self.sunken_city_l_bedroom = self.__add_region("Sunken City left area, bedroom",
AquariaLocations.locations_sunken_city_l_bedroom)
AquariaLocations.locations_sunken_city_l_bedroom
if add_locations else None)
self.sunken_city_r = self.__add_region("Sunken City right area", None)
self.sunken_city_r_crates = self.__add_region("Sunken City right area crates",
AquariaLocations.locations_sunken_city_r)
AquariaLocations.locations_sunken_city_r
if add_locations else None)
self.sunken_city_boss = self.__add_region("Sunken City boss area",
AquariaLocations.locations_sunken_city_boss)
AquariaLocations.locations_sunken_city_boss
if add_locations else None)
def __create_body(self) -> None:
def __create_body(self, add_locations: bool) -> None:
"""
Create the `body_*` and `final_boss* regions
"""
self.body_c = self.__add_region("The Body center area",
AquariaLocations.locations_body_c)
AquariaLocations.locations_body_c if add_locations else None)
self.body_l = self.__add_region("The Body left area",
AquariaLocations.locations_body_l)
AquariaLocations.locations_body_l if add_locations else None)
self.body_rt = self.__add_region("The Body right area, top path",
AquariaLocations.locations_body_rt)
AquariaLocations.locations_body_rt if add_locations else None)
self.body_rb = self.__add_region("The Body right area, bottom path",
AquariaLocations.locations_body_rb)
AquariaLocations.locations_body_rb if add_locations else None)
self.body_b = self.__add_region("The Body bottom area",
AquariaLocations.locations_body_b)
self.final_boss_loby = self.__add_region("The Body, before final boss", None)
AquariaLocations.locations_body_b if add_locations else None)
self.final_boss_lobby = self.__add_region("The Body, before final boss", None)
self.final_boss_tube = self.__add_region("The Body, final boss area turtle room",
AquariaLocations.locations_final_boss_tube)
AquariaLocations.locations_final_boss_tube
if add_locations else
AquariaLocations.locations_final_boss_tube_transturtle_only)
self.final_boss = self.__add_region("The Body, final boss",
AquariaLocations.locations_final_boss)
AquariaLocations.locations_final_boss if add_locations else None)
self.final_boss_end = self.__add_region("The Body, final boss area", None)
def get_entrance_name(self, from_region: Region, to_region: Region):
@staticmethod
def get_entrance_name(from_region: Region, to_region: Region):
"""
Return the name of an entrance between `from_region` and `to_region`
"""
@@ -510,6 +551,7 @@ class AquariaRegions:
entrance = Entrance(self.player, self.get_entrance_name(source_region, destination_region), source_region)
source_region.exits.append(entrance)
entrance.connect(destination_region)
if rule is not None:
set_rule(entrance, rule)
@@ -633,8 +675,8 @@ class AquariaRegions:
lambda state: _has_beast_form(state, self.player))
self.__connect_regions(self.mithalas_castle, self.cathedral_underground,
lambda state: _has_beast_form(state, self.player))
self.__connect_one_way_regions(self.mithalas_castle, self.cathedral_top_start,
lambda state: _has_bind_song(state, self.player))
self.__connect_regions(self.mithalas_castle, self.cathedral_top_start,
lambda state: _is_cathedral_door_opened(state, self.player))
self.__connect_one_way_regions(self.cathedral_top_start, self.cathedral_top_start_urns,
lambda state: _has_damaging_item(state, self.player))
self.__connect_regions(self.cathedral_top_start, self.cathedral_top_end,
@@ -708,16 +750,14 @@ class AquariaRegions:
self.__connect_regions(self.veil_tl, self.turtle_cave)
self.__connect_regions(self.turtle_cave, self.turtle_cave_bubble)
self.__connect_regions(self.veil_tr_r, self.sun_temple_r)
self.__connect_one_way_regions(self.sun_temple_r, self.sun_temple_l_entrance,
lambda state: _has_bind_song(state, self.player) or
lambda state: _has_sun_crystal(state, self.player) or
_has_light(state, self.player))
self.__connect_one_way_regions(self.sun_temple_l_entrance, self.sun_temple_r,
lambda state: _has_light(state, self.player))
self.__connect_regions(self.sun_temple_l_entrance, self.veil_tr_l)
self.__connect_regions(self.sun_temple_l, self.sun_temple_l_entrance)
self.__connect_one_way_regions(self.sun_temple_l, self.sun_temple_boss_path)
self.__connect_one_way_regions(self.sun_temple_boss_path, self.sun_temple_l)
self.__connect_regions(self.sun_temple_l, self.sun_temple_boss_path)
self.__connect_regions(self.sun_temple_boss_path, self.sun_temple_boss,
lambda state: _has_energy_attack_item(state, self.player))
self.__connect_one_way_regions(self.sun_temple_boss, self.veil_tr_l)
@@ -800,17 +840,31 @@ class AquariaRegions:
self.__connect_one_way_regions(self.body_rb, self.body_c)
self.__connect_regions(self.body_c, self.body_b,
lambda state: _has_dual_form(state, self.player))
self.__connect_regions(self.body_b, self.final_boss_loby,
self.__connect_regions(self.body_b, self.final_boss_lobby,
lambda state: _has_dual_form(state, self.player))
self.__connect_regions(self.final_boss_loby, self.final_boss_tube,
self.__connect_regions(self.final_boss_lobby, self.final_boss_tube,
lambda state: _has_nature_form(state, self.player))
self.__connect_one_way_regions(self.final_boss_loby, self.final_boss,
self.__connect_one_way_regions(self.final_boss_lobby, self.final_boss,
lambda state: _has_energy_form(state, self.player) and
_has_dual_form(state, self.player) and
_has_sun_form(state, self.player) and
_has_bind_song(state, self.player))
self.__connect_one_way_regions(self.final_boss, self.final_boss_end)
def __connect_four_gods_end(self, options: AquariaOptions) -> None:
"""
Connect an entrance for the four gods objective ending
"""
if options.mini_bosses_to_beat.value > 6:
victory_lambda = lambda state: (_has_four_gods_beated(state, self.player) and
_has_mini_bosses(state, self.player))
elif options.big_bosses_to_beat.value > 0:
victory_lambda = lambda state: (_has_four_gods_beated(state, self.player) and
_has_mini_bosses_four_gods(state, self.player))
else:
victory_lambda = lambda state: _has_four_gods_beated(state, self.player)
self.__connect_one_way_regions(self.menu, self.four_gods_end, victory_lambda)
def __connect_transturtle(self, item_target: str, region_source: Region, region_target: Region) -> None:
"""Connect a single transturtle to another one"""
if region_source != region_target:
@@ -824,10 +878,10 @@ class AquariaRegions:
self.__connect_transturtle(ItemNames.TRANSTURTLE_OPEN_WATERS, region, self.openwater_tr_turtle)
self.__connect_transturtle(ItemNames.TRANSTURTLE_KELP_FOREST, region, self.forest_bl)
self.__connect_transturtle(ItemNames.TRANSTURTLE_HOME_WATERS, region, self.home_water_transturtle)
self.__connect_transturtle(ItemNames.TRANSTURTLE_ABYSS, region, self.abyss_r_transturtle)
self.__connect_transturtle(ItemNames.TRANSTURTLE_BODY, region, self.final_boss_tube)
self.__connect_transturtle(ItemNames.TRANSTURTLE_SIMON_SAYS, region, self.simon)
self.__connect_transturtle(ItemNames.TRANSTURTLE_ARNASSI_RUINS, region, self.arnassi_cave_transturtle)
self.__connect_transturtle(ItemNames.TRANSTURTLE_ABYSS, region, self.abyss_r_transturtle)
self.__connect_transturtle(ItemNames.TRANSTURTLE_BODY, region, self.final_boss_tube)
def __connect_transturtles(self) -> None:
"""Connect every transturtle with others"""
@@ -836,12 +890,12 @@ class AquariaRegions:
self._connect_transturtle_to_other(self.openwater_tr_turtle)
self._connect_transturtle_to_other(self.forest_bl)
self._connect_transturtle_to_other(self.home_water_transturtle)
self._connect_transturtle_to_other(self.abyss_r_transturtle)
self._connect_transturtle_to_other(self.final_boss_tube)
self._connect_transturtle_to_other(self.simon)
self._connect_transturtle_to_other(self.arnassi_cave_transturtle)
self._connect_transturtle_to_other(self.abyss_r_transturtle)
self._connect_transturtle_to_other(self.final_boss_tube)
def connect_regions(self) -> None:
def connect_regions(self, options: AquariaOptions) -> None:
"""
Connect every region (entrances and exits)
"""
@@ -853,6 +907,8 @@ class AquariaRegions:
self.__connect_abyss_regions()
self.__connect_sunken_city_regions()
self.__connect_body_regions()
if self.is_four_gods:
self.__connect_four_gods_end(options)
self.__connect_transturtles()
def __add_event_location(self, region: Region, name: str, event_name: str) -> None:
@@ -934,21 +990,25 @@ class AquariaRegions:
AquariaLocationNames.THIRD_SECRET,
ItemNames.THIRD_SECRET_OBTAINED)
def add_event_locations(self) -> None:
def add_event_locations(self, options: AquariaOptions) -> None:
"""
Add every event (locations and items) to the `world`
"""
self.__add_event_mini_bosses()
self.__add_event_big_bosses()
self.__add_event_secrets()
self.__add_event_location(self.sunken_city_boss,
AquariaLocationNames.SUNKEN_CITY_CLEARED,
ItemNames.BODY_TONGUE_CLEARED)
self.__add_event_location(self.sun_temple_r,
AquariaLocationNames.SUN_CRYSTAL,
ItemNames.HAS_SUN_CRYSTAL)
self.__add_event_location(self.final_boss_end, AquariaLocationNames.OBJECTIVE_COMPLETE,
ItemNames.VICTORY)
self.__add_event_big_bosses()
self.__add_event_location(self.sunken_city_boss,
AquariaLocationNames.SUNKEN_CITY_CLEARED,
ItemNames.BODY_TONGUE_CLEARED)
if self.is_four_gods:
self.__add_event_location(self.four_gods_end, AquariaLocationNames.OBJECTIVE_COMPLETE,
ItemNames.VICTORY)
else:
self.__add_event_secrets()
self.__add_event_location(self.final_boss_end, AquariaLocationNames.OBJECTIVE_COMPLETE,
ItemNames.VICTORY)
def __adjusting_soup_rules(self) -> None:
"""
@@ -958,16 +1018,16 @@ class AquariaRegions:
lambda state: _has_hot_soup(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, self.player),
lambda state: _has_beast_and_soup_form(state, self.player) or
state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player), combine="or")
state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, self.player),
lambda state: _has_beast_and_soup_form(state, self.player) or
state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player), combine="or")
state.has(ItemNames.LUMEREAN_GOD_BEATED, self.player))
add_rule(
self.multiworld.get_location(AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
self.player),
lambda state: _has_beast_and_soup_form(state, self.player))
def __adjusting_under_rock_location(self) -> None:
def __adjusting_under_rock_location(self, options: AquariaOptions) -> None:
"""
Modify rules implying bind song needed for bulb under rocks
"""
@@ -999,21 +1059,29 @@ class AquariaRegions:
add_rule(self.multiworld.get_location(
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH,
self.player), lambda state: _has_bind_song(state, self.player))
add_rule(
self.multiworld.get_location(AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM,
self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH,
self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location(
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH,
self.player), lambda state: _has_bind_song(state, self.player))
if not self.is_four_gods:
add_rule(self.multiworld.get_location(
AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_BEHIND_THE_ROCK_IN_THE_WHALE_ROOM,
self.player), lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_RIGHT_AREA_BULB_IN_THE_MIDDLE_PATH,
self.player), lambda state: _has_bind_song(state, self.player))
def __adjusting_light_in_dark_place_rules(self) -> None:
def __adjusting_light_in_dark_place_rules(self, light_option: LightNeededToGetToDarkPlaces) -> None:
"""
Modify rules implying that the player needs a light to go in dark places
"""
if light_option == LightNeededToGetToDarkPlaces.option_sun_form:
light_lambda = _has_sun_form
else:
light_lambda = _has_light
add_rule(self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL, self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
add_rule(
self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER, self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.sun_temple_l_entrance, self.sun_temple_l),
self.player), lambda state: _has_light(state, self.player) or
_has_sun_crystal(state, self.player))
@@ -1022,15 +1090,15 @@ class AquariaRegions:
_has_sun_crystal(state, self.player))
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.abyss_r_transturtle, self.abyss_r),
self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.body_c, self.abyss_lb), self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.openwater_br, self.abyss_r), self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.openwater_bl, self.abyss_l), self.player),
lambda state: _has_light(state, self.player))
lambda state: light_lambda(state, self.player))
def __adjusting_manual_rules(self) -> None:
def __adjusting_manual_rules(self, options: AquariaOptions) -> None:
add_rule(self.multiworld.get_location(AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS, self.player),
lambda state: _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location(
@@ -1045,9 +1113,6 @@ class AquariaRegions:
lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG, self.player),
lambda state: _has_bind_song(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS,
self.player),
lambda state: _has_fish_form(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SONG_CAVE_ANEMONE_SEED, self.player),
lambda state: _has_nature_form(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SONG_CAVE_VERSE_EGG, self.player),
@@ -1076,19 +1141,21 @@ class AquariaRegions:
), lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG, self.player),
lambda state: _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, self.player),
lambda state: state.has("Sun God beated", self.player))
add_rule(self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, self.player),
lambda state: state.has("Sun God beated", self.player))
add_rule(
self.multiworld.get_location(AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, self.player),
lambda state: _has_tongue_cleared(state, self.player))
add_rule(self.multiworld.get_location(
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS,
self.player), lambda state: _has_bind_song(state, self.player)
)
add_rule(self.multiworld.get_location(AquariaLocationNames.SITTING_ON_THRONE, self.player),
lambda state: _has_bind_song(state, self.player))
if not self.is_four_gods:
add_rule(self.multiworld.get_location(AquariaLocationNames.ABYSS_LEFT_AREA_BULB_IN_THE_BOTTOM_FISH_PASS,
self.player),
lambda state: _has_fish_form(state, self.player))
add_rule(
self.multiworld.get_location(AquariaLocationNames.THE_BODY_CENTER_AREA_BREAKING_LI_S_CAGE, self.player),
lambda state: _has_tongue_cleared(state, self.player))
def __no_progression_hard_or_hidden_location(self) -> None:
def __no_progression_hard_or_hidden_location(self, options: AquariaOptions) -> None:
self.multiworld.get_location(AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD,
@@ -1097,8 +1164,6 @@ class AquariaRegions:
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
@@ -1109,25 +1174,12 @@ class AquariaRegions:
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS,
self.player).item_rule = _item_not_advancement
@@ -1135,33 +1187,183 @@ class AquariaRegions:
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_SUN_KEY,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
self.player).item_rule = _item_not_advancement
if not self.is_four_gods:
self.multiworld.get_location(AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.THE_BODY_BOTTOM_AREA_MUTANT_COSTUME,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.FINAL_BOSS_AREA_BULB_IN_THE_BOSS_THIRD_FORM_ROOM,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(
AquariaLocationNames.KING_JELLYFISH_CAVE_BULB_IN_THE_RIGHT_PATH_FROM_KING_JELLY,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.KING_JELLYFISH_CAVE_JELLYFISH_COSTUME,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL,
self.player).item_rule = _item_not_advancement
self.multiworld.get_location(AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG,
self.player).item_rule = _item_not_advancement
def __no_progression_area(self, area: dict) -> None:
"""Be sure to not put any progression items in location of an `area`"""
for location in area:
self.multiworld.get_location(location, self.player).item_rule = _item_not_advancement
def __no_progression_kelp_forest(self) -> None:
"""Be sure to not put any progression items in Kelp forest"""
self.__no_progression_area(AquariaLocations.locations_forest_tl)
self.__no_progression_area(AquariaLocations.locations_forest_tl_verse_egg_room)
self.__no_progression_area(AquariaLocations.locations_forest_tr)
self.__no_progression_area(AquariaLocations.locations_forest_tr_fp)
self.__no_progression_area(AquariaLocations.locations_forest_bl)
self.__no_progression_area(AquariaLocations.locations_forest_bl_sc)
self.__no_progression_area(AquariaLocations.locations_forest_br)
self.__no_progression_area(AquariaLocations.locations_forest_boss)
self.__no_progression_area(AquariaLocations.locations_forest_boss_entrance)
self.__no_progression_area(AquariaLocations.locations_forest_fish_cave)
self.__no_progression_area(AquariaLocations.locations_sprite_cave)
self.__no_progression_area(AquariaLocations.locations_sprite_cave_tube)
self.__no_progression_area(AquariaLocations.locations_mermog_cave)
self.__no_progression_area(AquariaLocations.locations_mermog_boss)
def __no_progression_veil(self) -> None:
"""Be sure to not put any progression items in The Veil"""
self.__no_progression_area(AquariaLocations.locations_veil_tl)
self.__no_progression_area(AquariaLocations.locations_veil_tl_fp)
self.__no_progression_area(AquariaLocations.locations_turtle_cave)
self.__no_progression_area(AquariaLocations.locations_turtle_cave_bubble)
self.__no_progression_area(AquariaLocations.locations_veil_tr_r)
self.__no_progression_area(AquariaLocations.locations_veil_tr_l)
self.__no_progression_area(AquariaLocations.locations_veil_b)
self.__no_progression_area(AquariaLocations.locations_veil_b_sc)
self.__no_progression_area(AquariaLocations.locations_veil_b_fp)
self.__no_progression_area(AquariaLocations.locations_veil_br)
self.__no_progression_area(AquariaLocations.locations_octo_cave_t)
self.__no_progression_area(AquariaLocations.locations_octo_cave_b)
self.__no_progression_area(AquariaLocations.locations_sun_temple_l)
self.__no_progression_area(AquariaLocations.locations_sun_temple_r)
self.__no_progression_area(AquariaLocations.locations_sun_temple_boss_path)
self.__no_progression_area(AquariaLocations.locations_sun_temple_boss)
def __no_progression_mithalas(self) -> None:
"""Be sure to not put any progression items in Mithalas"""
self.__no_progression_area(AquariaLocations.locations_mithalas_city)
self.__no_progression_area(AquariaLocations.locations_mithalas_city_urns)
self.__no_progression_area(AquariaLocations.locations_mithalas_city_top_path)
self.__no_progression_area(AquariaLocations.locations_mithalas_city_fishpass)
self.__no_progression_area(AquariaLocations.locations_mithalas_castle)
self.__no_progression_area(AquariaLocations.locations_mithalas_castle_urns)
self.__no_progression_area(AquariaLocations.locations_mithalas_castle_tube)
self.__no_progression_area(AquariaLocations.locations_mithalas_castle_sc)
self.__no_progression_area(AquariaLocations.locations_cathedral_top_start)
self.__no_progression_area(AquariaLocations.locations_cathedral_top_start_urns)
self.__no_progression_area(AquariaLocations.locations_cathedral_top_end)
self.__no_progression_area(AquariaLocations.locations_cathedral_underground)
self.__no_progression_area(AquariaLocations.locations_cathedral_boss)
def __no_progression_energy_temple(self) -> None:
"""Be sure to not put any progression items in the Energy Temple"""
self.__no_progression_area(AquariaLocations.locations_energy_temple_1)
self.__no_progression_area(AquariaLocations.locations_energy_temple_idol)
self.__no_progression_area(AquariaLocations.locations_energy_temple_2)
self.__no_progression_area(AquariaLocations.locations_energy_temple_altar)
self.__no_progression_area(AquariaLocations.locations_energy_temple_3)
self.__no_progression_area(AquariaLocations.locations_energy_temple_boss)
self.__no_progression_area(AquariaLocations.locations_energy_temple_blaster_room)
def __no_progression_arnassi_ruins(self, options: AquariaOptions) -> None:
"""Be sure to not put any progression items in the Arnassi ruins"""
self.__no_progression_area(AquariaLocations.locations_arnassi)
self.__no_progression_area(AquariaLocations.locations_arnassi_cave)
self.__no_progression_area(AquariaLocations.locations_arnassi_cave_transturtle)
self.__no_progression_area(AquariaLocations.locations_arnassi_crab_boss)
if options.turtle_randomizer == 0:
self.__no_progression_area(AquariaLocations.locations_simon)
def __no_progression_frozen_veil(self) -> None:
"""Be sure to not put any progression items in the Frozen Veil"""
self.__no_progression_area(AquariaLocations.locations_ice_cave)
self.__no_progression_area(AquariaLocations.locations_bubble_cave)
self.__no_progression_area(AquariaLocations.locations_bubble_cave_boss)
def __no_progression_abyss(self) -> None:
"""Be sure to not put any progression items in the Abyss"""
self.__no_progression_area(AquariaLocations.locations_abyss_l)
self.__no_progression_area(AquariaLocations.locations_abyss_lb)
self.__no_progression_area(AquariaLocations.locations_abyss_r)
self.__no_progression_area(AquariaLocations.locations_abyss_r_whale)
self.__no_progression_area(AquariaLocations.locations_abyss_r_transturtle)
self.__no_progression_area(AquariaLocations.locations_king_jellyfish_cave)
def __no_progression_sunken_city(self) -> None:
"""Be sure to not put any progression items in the Sunken City"""
self.__no_progression_area(AquariaLocations.locations_sunken_city_r)
self.__no_progression_area(AquariaLocations.locations_sunken_city_l)
self.__no_progression_area(AquariaLocations.locations_sunken_city_l_bedroom)
self.__no_progression_area(AquariaLocations.locations_sunken_city_boss)
def __no_progression_body(self) -> None:
"""Be sure to not put any progression items in the Body"""
self.__no_progression_area(AquariaLocations.locations_body_c)
self.__no_progression_area(AquariaLocations.locations_body_l)
self.__no_progression_area(AquariaLocations.locations_body_rt)
self.__no_progression_area(AquariaLocations.locations_body_rb)
self.__no_progression_area(AquariaLocations.locations_body_b)
self.__no_progression_area(AquariaLocations.locations_final_boss_tube)
self.__no_progression_area(AquariaLocations.locations_final_boss)
def __no_progression_areas(self, options: AquariaOptions) -> None:
"""Manage options that remove progression items from areas around the Aquaria world"""
if options.no_progression_simon_says:
self.__no_progression_area(AquariaLocations.locations_simon)
if options.no_progression_kelp_forest:
self.__no_progression_kelp_forest()
if options.no_progression_veil:
self.__no_progression_veil()
if options.no_progression_mithalas:
self.__no_progression_mithalas()
if options.no_progression_energy_temple:
self.__no_progression_energy_temple()
if options.no_progression_arnassi_ruins:
self.__no_progression_arnassi_ruins(options)
if not self.is_four_gods:
if options.no_progression_frozen_veil:
self.__no_progression_frozen_veil()
if options.no_progression_abyss:
self.__no_progression_abyss()
if options.no_progression_sunken_city:
self.__no_progression_sunken_city()
if options.no_progression_body:
self.__no_progression_body()
def adjusting_rules(self, options: AquariaOptions) -> None:
"""
Modify rules for single location or optional rules
"""
self.__adjusting_manual_rules()
self.__adjusting_manual_rules(options)
self.__adjusting_soup_rules()
if options.light_needed_to_get_to_dark_places:
self.__adjusting_light_in_dark_place_rules()
self.__no_progression_areas(options)
if options.light_needed_to_get_to_dark_places != LightNeededToGetToDarkPlaces.option_off:
self.__adjusting_light_in_dark_place_rules(options.light_needed_to_get_to_dark_places)
if options.bind_song_needed_to_get_under_rock_bulb:
self.__adjusting_under_rock_location()
if options.mini_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss),
self.player), lambda state: _has_mini_bosses(state, self.player))
if options.big_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss),
self.player), lambda state: _has_big_bosses(state, self.player))
if options.objective.value == options.objective.option_obtain_secrets_and_kill_the_creator:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_loby, self.final_boss),
self.player), lambda state: _has_secrets(state, self.player))
self.__adjusting_under_rock_location(options)
if not self.is_four_gods:
if options.mini_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_lobby, self.final_boss),
self.player), lambda state: _has_mini_bosses(state, self.player))
if options.big_bosses_to_beat.value > 0:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_lobby, self.final_boss),
self.player), lambda state: _has_big_bosses(state, self.player))
if options.objective.value == options.objective.option_obtain_secrets_and_kill_the_creator:
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.final_boss_lobby, self.final_boss),
self.player), lambda state: _has_secrets(state, self.player))
if (options.unconfine_home_water.value == UnconfineHomeWater.option_via_energy_door or
options.unconfine_home_water.value == UnconfineHomeWater.option_off):
add_rule(self.multiworld.get_entrance(self.get_entrance_name(self.home_water, self.home_water_transturtle),
@@ -1173,7 +1375,7 @@ class AquariaRegions:
lambda state: _has_bind_song(state, self.player) and
_has_energy_attack_item(state, self.player))
if options.no_progression_hard_or_hidden_locations:
self.__no_progression_hard_or_hidden_location()
self.__no_progression_hard_or_hidden_location(options)
def __add_home_water_regions_to_world(self) -> None:
"""
@@ -1298,12 +1500,12 @@ class AquariaRegions:
self.multiworld.regions.append(self.body_rt)
self.multiworld.regions.append(self.body_rb)
self.multiworld.regions.append(self.body_b)
self.multiworld.regions.append(self.final_boss_loby)
self.multiworld.regions.append(self.final_boss_lobby)
self.multiworld.regions.append(self.final_boss_tube)
self.multiworld.regions.append(self.final_boss)
self.multiworld.regions.append(self.final_boss_end)
def add_regions_to_world(self) -> None:
def add_regions_to_world(self, options: AquariaOptions) -> None:
"""
Add every region to the `world`
"""
@@ -1314,13 +1516,17 @@ class AquariaRegions:
self.__add_veil_regions_to_world()
self.__add_abyss_regions_to_world()
self.__add_body_regions_to_world()
if self.is_four_gods:
self.multiworld.regions.append(self.four_gods_end)
def __init__(self, multiworld: MultiWorld, player: int):
def __init__(self, multiworld: MultiWorld, player: int, options: AquariaOptions):
"""
Initialisation of the regions
"""
self.multiworld = multiworld
self.player = player
self.is_four_gods = (options.objective.value == Objective.option_killing_the_four_gods or
options.objective.value == Objective.option_gods_and_creator)
self.__create_home_water_area()
self.__create_energy_temple()
self.__create_openwater()
@@ -1328,6 +1534,8 @@ class AquariaRegions:
self.__create_forest()
self.__create_veil()
self.__create_sun_temple()
self.__create_abyss()
self.__create_sunken_city()
self.__create_body()
self.__create_abyss(not self.is_four_gods)
self.__create_sunken_city(not self.is_four_gods)
self.__create_body(not self.is_four_gods)
if self.is_four_gods:
self.four_gods_end = self.__add_region("Four gods ending", None)

View File

@@ -7,12 +7,14 @@ Description: Main module for Aquaria game multiworld randomizer
from typing import List, Dict, ClassVar, Any
from worlds.AutoWorld import World, WebWorld
from BaseClasses import Tutorial, MultiWorld, ItemClassification
from .Items import item_table, AquariaItem, ItemType, ItemGroup, ItemNames
from .Items import item_table, AquariaItem, ItemType, ItemGroup, ItemNames, four_gods_excludes
from .Locations import location_table, AquariaLocationNames
from .Options import (AquariaOptions, IngredientRandomizer, TurtleRandomizer, EarlyBindSong, EarlyEnergyForm,
UnconfineHomeWater, Objective)
from .Regions import AquariaRegions
CLIENT_MINIMAL_COMPATIBILITY = [1, 4, 1]
class AquariaWeb(WebWorld):
"""
@@ -98,6 +100,7 @@ class AquariaWorld(World):
"Used to manage Regions"
exclude: List[str]
"The items that should not be added to the multiworld item pool"
def __init__(self, multiworld: MultiWorld, player: int):
"""Initialisation of the Aquaria World"""
@@ -111,15 +114,15 @@ class AquariaWorld(World):
Run before any general steps of the MultiWorld other than options. Useful for getting and adjusting option
results and determining layouts for entrance rando etc. start inventory gets pushed after this step.
"""
self.regions = AquariaRegions(self.multiworld, self.player)
self.regions = AquariaRegions(self.multiworld, self.player, self.options)
def create_regions(self) -> None:
"""
Create every Region in `regions`
"""
self.regions.add_regions_to_world()
self.regions.connect_regions()
self.regions.add_event_locations()
self.regions.add_regions_to_world(self.options)
self.regions.connect_regions(self.options)
self.regions.add_event_locations(self.options)
def create_item(self, name: str) -> AquariaItem:
"""
@@ -157,8 +160,17 @@ class AquariaWorld(World):
def create_items(self) -> None:
"""Create every item in the world"""
precollected = [item.name for item in self.multiworld.precollected_items[self.player]]
if (self.options.objective.value == Objective.option_killing_the_four_gods or
self.options.objective.value == Objective.option_gods_and_creator):
self.exclude.extend(four_gods_excludes)
self.__pre_fill_item(ItemNames.TRANSTURTLE_ABYSS, AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE,
precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE,
precollected)
if self.options.turtle_randomizer.value != TurtleRandomizer.option_none:
if self.options.turtle_randomizer.value == TurtleRandomizer.option_all_except_final:
if (self.options.turtle_randomizer.value == TurtleRandomizer.option_all_except_final and
self.options.objective.value != Objective.option_killing_the_four_gods and
self.options.objective.value != Objective.option_gods_and_creator):
self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE,
precollected)
else:
@@ -167,25 +179,29 @@ class AquariaWorld(World):
self.__pre_fill_item(ItemNames.TRANSTURTLE_VEIL_TOP_RIGHT,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE, precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_OPEN_WATERS,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE,
precollected)
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE, precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_KELP_FOREST,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE,
precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_HOME_WATERS, AquariaLocationNames.HOME_WATERS_TRANSTURTLE,
precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_ABYSS, AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE,
precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE,
precollected)
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE, precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_HOME_WATERS, AquariaLocationNames.HOME_WATERS_TRANSTURTLE, precollected)
if (self.options.objective.value != Objective.option_killing_the_four_gods and
self.options.objective.value != Objective.option_gods_and_creator):
self.__pre_fill_item(ItemNames.TRANSTURTLE_ABYSS, AquariaLocationNames.ABYSS_RIGHT_AREA_TRANSTURTLE,
precollected)
self.__pre_fill_item(ItemNames.TRANSTURTLE_BODY, AquariaLocationNames.FINAL_BOSS_AREA_TRANSTURTLE,
precollected)
# The last two are inverted because in the original game, they are special turtle that communicate directly
self.__pre_fill_item(ItemNames.TRANSTURTLE_SIMON_SAYS, AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE,
precollected, ItemClassification.progression)
self.__pre_fill_item(ItemNames.TRANSTURTLE_ARNASSI_RUINS, AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE,
precollected)
if not self.options.throne_as_location:
self.__pre_fill_item(ItemNames.DOOR_TO_CATHEDRAL, AquariaLocationNames.SITTING_ON_THRONE,
precollected, ItemClassification.progression)
for name, data in item_table.items():
if name not in self.exclude:
for i in range(data.count):
for i in range(data.count):
if name in self.exclude:
self.exclude.remove(name)
else:
item = self.create_item(name)
self.multiworld.itempool.append(item)
@@ -227,22 +243,41 @@ class AquariaWorld(World):
self.ingredients_substitution.extend(dishes_substitution)
def fill_slot_data(self) -> Dict[str, Any]:
"""
Send some useful information to the client.
"""
return {"ingredientReplacement": self.ingredients_substitution,
"aquarian_translate": bool(self.options.aquarian_translation.value),
"blind_goal": bool(self.options.blind_goal.value),
"secret_needed":
self.options.objective.value == Objective.option_obtain_secrets_and_kill_the_creator,
"goal": self.options.objective.value,
"minibosses_to_kill": self.options.mini_bosses_to_beat.value,
"bigbosses_to_kill": self.options.big_bosses_to_beat.value,
"skip_first_vision": bool(self.options.skip_first_vision.value),
"skip_final_boss_3rd_form": bool(self.options.skip_final_boss_3rd_form.value),
"infinite_hot_soup": bool(self.options.infinite_hot_soup.value),
"open_body_tongue": bool(self.options.open_body_tongue.value),
"unconfine_home_water_energy_door":
self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_energy_door
or self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_both,
"unconfine_home_water_transturtle":
self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_transturtle
or self.options.unconfine_home_water.value == UnconfineHomeWater.option_via_both,
"maximum_ingredient_amount": self.options.maximum_ingredient_amount.value,
"bind_song_needed_to_get_under_rock_bulb": bool(self.options.bind_song_needed_to_get_under_rock_bulb),
"no_progression_hard_or_hidden_locations": bool(self.options.no_progression_hard_or_hidden_locations),
"light_needed_to_get_to_dark_places": bool(self.options.light_needed_to_get_to_dark_places),
"turtle_randomizer": self.options.turtle_randomizer.value
"turtle_randomizer": self.options.turtle_randomizer.value,
"no_progression_simon_says": bool(self.options.no_progression_simon_says),
"no_progression_kelp_forest": bool(self.options.no_progression_kelp_forest),
"no_progression_veil": bool(self.options.no_progression_veil),
"no_progression_mithalas": bool(self.options.no_progression_mithalas),
"no_progression_energy_temple": bool(self.options.no_progression_energy_temple),
"no_progression_arnassi_ruins": bool(self.options.no_progression_arnassi_ruins),
"no_progression_frozen_veil": bool(self.options.no_progression_frozen_veil),
"no_progression_abyss": bool(self.options.no_progression_abyss),
"no_progression_sunken_city": bool(self.options.no_progression_sunken_city),
"no_progression_body": bool(self.options.no_progression_body),
"save_healing": bool(self.options.save_healing),
"throne_as_location": bool(self.options.throne_as_location),
"required_client_version": CLIENT_MINIMAL_COMPATIBILITY,
}

View File

@@ -0,0 +1,185 @@
"""
Author: Louis M
Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with the goal four gods
"""
from . import AquariaTestBase
from ..Items import ItemNames
from ..Locations import AquariaLocationNames
from ..Options import UnconfineHomeWater, Objective, BindSongNeededToGetUnderRockBulb
class FourGodsAccessTest(AquariaTestBase):
"""Unit test used to test accessibility of locations with the goal four gods"""
options = {
"objective": Objective.option_killing_the_four_gods,
"bind_song_needed_to_get_under_rock_bulb": BindSongNeededToGetUnderRockBulb.option_true
}
def test_locations(self) -> None:
"""Test locations with the goal four gods"""
locations = [
AquariaLocationNames.VERSE_CAVE_RIGHT_AREA_BIG_SEED,
AquariaLocationNames.VERSE_CAVE_LEFT_AREA_BULB_UNDER_THE_ROCK_AT_THE_END_OF_THE_PATH,
AquariaLocationNames.HOME_WATERS_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH_FROM_THE_VERSE_CAVE,
AquariaLocationNames.HOME_WATERS_BULB_IN_THE_PATH_BELOW_NAUTILUS_PRIME,
AquariaLocationNames.HOME_WATERS_BULB_IN_THE_BOTTOM_LEFT_ROOM,
AquariaLocationNames.HOME_WATERS_NAUTILUS_EGG,
AquariaLocationNames.HOME_WATERS_TRANSTURTLE,
AquariaLocationNames.NAIJA_S_HOME_BULB_AFTER_THE_ENERGY_DOOR,
AquariaLocationNames.NAIJA_S_HOME_BULB_UNDER_THE_ROCK_AT_THE_RIGHT_OF_THE_MAIN_PATH,
AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_IN_THE_PATH_TO_THE_SINGING_STATUES,
AquariaLocationNames.SONG_CAVE_BULB_UNDER_THE_ROCK_CLOSE_TO_THE_SONG_DOOR,
AquariaLocationNames.SONG_CAVE_VERSE_EGG,
AquariaLocationNames.SONG_CAVE_ANEMONE_SEED,
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE,
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL,
AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR,
AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH,
AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH,
AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_LEFT_PATH,
AquariaLocationNames.OPEN_WATERS_TOP_LEFT_AREA_BULB_TO_THE_RIGHT_OF_THE_SAVE_CRYSTAL,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_SMALL_PATH_BEFORE_MITHALAS,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_PATH_FROM_THE_LEFT_ENTRANCE,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_CLEARING_CLOSE_TO_THE_BOTTOM_EXIT,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_CLOSE_TO_THE_SAVE_CRYSTAL,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_BIG_CLEARING_TO_THE_TOP_EXIT,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_BULB_IN_THE_TURTLE_ROOM,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_TRANSTURTLE,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_FIRST_URN_IN_THE_MITHALAS_EXIT,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_SECOND_URN_IN_THE_MITHALAS_EXIT,
AquariaLocationNames.OPEN_WATERS_TOP_RIGHT_AREA_THIRD_URN_IN_THE_MITHALAS_EXIT,
AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_BEHIND_THE_CHOMPER_FISH,
AquariaLocationNames.OPEN_WATERS_BOTTOM_LEFT_AREA_BULB_INSIDE_THE_LOWEST_FISH_PASS,
AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_CLOSE_TO_THE_RIGHT_EXIT,
AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_BULB_BEHIND_THE_CHOMPER_FISH,
AquariaLocationNames.OPEN_WATERS_SKELETON_PATH_KING_SKULL,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_LEFT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_CENTER_PART,
AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_STATUE,
AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE,
AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR,
AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS,
AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_THE_LEFT_CITY_PART,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_THE_LEFT_CITY_PART,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_RIGHT_PART,
AquariaLocationNames.MITHALAS_CITY_BULB_AT_THE_TOP_OF_THE_CITY,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_A_BROKEN_HOME,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_A_BROKEN_HOME,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_BOTTOM_LEFT_PART,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_THIRD_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_MITHALAS_POT,
AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE,
AquariaLocationNames.MITHALAS_CITY_DOLL,
AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BLUE_BANNER,
AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BEDROOM,
AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_OF_THE_SINGLE_LAMP_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_OF_THE_SINGLE_LAMP_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BOTTOM_ROOM,
AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_ON_THE_ENTRANCE_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_ON_THE_ENTRANCE_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS,
AquariaLocationNames.MITHALAS_CITY_CASTLE_TRIDENT_HEAD,
AquariaLocationNames.MITHALAS_CATHEDRAL_BULB_IN_THE_FLESH_ROOM_WITH_FLEAS,
AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_IN_THE_TOP_LEFT_EYES_BOSS_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_FOURTH_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BELOW_THE_LEFT_ENTRANCE,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_CENTER_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_FIRST_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_SECOND_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_THIRD_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_CLOSE_TO_THE_SAVE_CRYSTAL,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE,
AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER,
AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD,
AquariaLocationNames.KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG,
AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE,
AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG,
AquariaLocationNames.TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF,
AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_STONE_HEAD,
AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM,
AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR,
AquariaLocationNames.SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE,
AquariaLocationNames.SUN_TEMPLE_BULB_ON_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_SUN_KEY,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD,
]
items = [[ItemNames.ENERGY_FORM, ItemNames.SUN_FORM, ItemNames.BEAST_FORM, ItemNames.SPIRIT_FORM,
ItemNames.FISH_FORM, ItemNames.HOT_SOUP, ItemNames.BIND_SONG, ItemNames.NATURE_FORM,
ItemNames.DUAL_FORM]]
self.assertAccessDependency(locations, items, True)

View File

@@ -0,0 +1,35 @@
"""
Author: Louis M
Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the beast form or hot soup
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
from ..Items import ItemNames
class BeastOrSoupAccessTest(AquariaTestBase):
"""Unit test used to test accessibility of locations with and without the beast form or hot soup"""
def test_beast_or_soup_location(self) -> None:
"""Test locations that require beast form or hot soup"""
locations = [
AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG,
AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG,
AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
AquariaLocationNames.SUNKEN_CITY_BULB_ON_TOP_OF_THE_BOSS_AREA,
AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL,
AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG,
AquariaLocationNames.BEATING_MANTIS_SHRIMP_PRIME,
AquariaLocationNames.BEATING_THE_GOLEM,
AquariaLocationNames.BEATING_MERGOG,
AquariaLocationNames.BEATING_OCTOPUS_PRIME,
AquariaLocationNames.SUNKEN_CITY_CLEARED
]
items = [[ItemNames.BEAST_FORM, ItemNames.HOT_SOUP, ItemNames.HOT_SOUP_X_2]]
self.assertAccessDependency(locations, items)

View File

@@ -35,6 +35,7 @@ class BindSongAccessTest(AquariaTestBase):
AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH,
AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
AquariaLocationNames.SITTING_ON_THRONE,
*after_home_water_locations
]
items = [[ItemNames.BIND_SONG]]

View File

@@ -38,6 +38,7 @@ class BindSongOptionAccessTest(AquariaTestBase):
AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH,
AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
AquariaLocationNames.SITTING_ON_THRONE,
*after_home_water_locations
]
items = [[ItemNames.BIND_SONG]]

View File

@@ -0,0 +1,44 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Arnassi Ruins when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionArnassiRuinsTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Arnassi Ruins when option enabled"""
options = {
"no_progression_arnassi_ruins": True
}
unfillable_locations = [
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_LEFT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_CENTER_PART,
AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_STATUE,
AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE,
AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR,
]
def test_no_progression_arnassi_ruins(self) -> None:
"""
Unit test used to test that no progression items can be put in Arnassi Ruins when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,44 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Energy Temple when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionEnergyTempleTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Energy Temple when option enabled"""
options = {
"no_progression_energy_temple": True
}
unfillable_locations = [
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE,
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL,
AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR,
AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH,
AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
]
def test_no_progression_energy_temple(self) -> None:
"""
Unit test used to test that no progression items can be put in Energy Temple when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,44 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Frozen Veil when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionFrozenVeilTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Frozen Veil when option enabled"""
options = {
"no_progression_frozen_veil": True
}
unfillable_locations = [
AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT,
AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL,
AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG,
]
def test_no_progression_frozen_veil(self) -> None:
"""
Unit test used to test that no progression items can be put in Frozen Veil when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -9,7 +9,7 @@ from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class UNoProgressionHardHiddenTest(AquariaTestBase):
class NoProgressionHardHiddenTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in hard or hidden locations when option enabled"""
options = {
"no_progression_hard_or_hidden_locations": True
@@ -43,7 +43,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
]
def test_unconfine_home_water_both_location_fillable(self) -> None:
def test_no_progression_hard_or_hidden(self) -> None:
"""
Unit test used to test that no progression items can be put in hard or hidden locations when option enabled
"""

View File

@@ -0,0 +1,61 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Kelp Forest when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionKelpForestTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Kelp Forest when option enabled"""
options = {
"no_progression_kelp_forest": True
}
unfillable_locations = [
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER,
AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD,
AquariaLocationNames.KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG,
AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE,
AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG,
]
def test_no_progression_kelp_forest(self) -> None:
"""
Unit test used to test that no progression items can be put in Kelp Forest when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,88 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Mithalas when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionMithalasTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Mithalas when option enabled"""
options = {
"no_progression_mithalas": True
}
unfillable_locations = [
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_THE_LEFT_CITY_PART,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_THE_LEFT_CITY_PART,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_RIGHT_PART,
AquariaLocationNames.MITHALAS_CITY_BULB_AT_THE_TOP_OF_THE_CITY,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_A_BROKEN_HOME,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_A_BROKEN_HOME,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_BOTTOM_LEFT_PART,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_ONE_OF_THE_HOMES,
AquariaLocationNames.MITHALAS_CITY_FIRST_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_SECOND_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_THIRD_URN_IN_THE_CITY_RESERVE,
AquariaLocationNames.MITHALAS_CITY_FIRST_BULB_AT_THE_END_OF_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_SECOND_BULB_AT_THE_END_OF_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_BULB_IN_THE_TOP_PATH,
AquariaLocationNames.MITHALAS_CITY_MITHALAS_POT,
AquariaLocationNames.MITHALAS_CITY_URN_IN_THE_CASTLE_FLOWER_TUBE_ENTRANCE,
AquariaLocationNames.MITHALAS_CITY_DOLL,
AquariaLocationNames.MITHALAS_CITY_URN_INSIDE_A_HOME_FISH_PASS,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BULB_IN_THE_FLESH_HOLE,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BLUE_BANNER,
AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BEDROOM,
AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_OF_THE_SINGLE_LAMP_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_OF_THE_SINGLE_LAMP_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_URN_IN_THE_BOTTOM_ROOM,
AquariaLocationNames.MITHALAS_CITY_CASTLE_FIRST_URN_ON_THE_ENTRANCE_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_SECOND_URN_ON_THE_ENTRANCE_PATH,
AquariaLocationNames.MITHALAS_CITY_CASTLE_BEATING_THE_PRIESTS,
AquariaLocationNames.MITHALAS_CITY_CASTLE_TRIDENT_HEAD,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_BULB_IN_THE_FLESH_ROOM_WITH_FLEAS,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_IN_THE_TOP_LEFT_EYES_BOSS_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_FIRST_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_SECOND_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_THIRD_URN_IN_THE_PATH_BEHIND_THE_FLESH_VEIN,
AquariaLocationNames.MITHALAS_CATHEDRAL_FOURTH_URN_IN_THE_TOP_RIGHT_ROOM,
AquariaLocationNames.MITHALAS_CATHEDRAL_MITHALAN_DRESS,
AquariaLocationNames.MITHALAS_CATHEDRAL_URN_BELOW_THE_LEFT_ENTRANCE,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_CENTER_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_FIRST_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_SECOND_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_THIRD_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_CLOSE_TO_THE_SAVE_CRYSTAL,
AquariaLocationNames.CATHEDRAL_UNDERGROUND_BULB_IN_THE_BOTTOM_RIGHT_PATH,
AquariaLocationNames.MITHALAS_BOSS_AREA_BEATING_MITHALAN_GOD,
]
def test_no_progression_mithalas(self) -> None:
"""
Unit test used to test that no progression items can be put in Mithalas when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,38 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in Simon says when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionSimonSaysTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in Simon says when option enabled"""
options = {
"no_progression_simon_says": True
}
unfillable_locations = [
AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS,
AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE,
]
def test_no_progression_simon_says(self) -> None:
"""
Unit test used to test that no progression items can be put in Simon says when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,67 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in the Veil when option enabled
"""
from . import AquariaTestBase
from BaseClasses import ItemClassification
from ..Locations import AquariaLocationNames
class NoProgressionVeilTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in the Veil when option enabled"""
options = {
"no_progression_veil": True
}
unfillable_locations = [
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG,
AquariaLocationNames.TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF,
AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_STONE_HEAD,
AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM,
AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR,
AquariaLocationNames.SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE,
AquariaLocationNames.SUN_TEMPLE_BULB_ON_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_SUN_KEY,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD,
]
def test_no_progression_veil(self) -> None:
"""
Unit test used to test that no progression items can be put in the Veil when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")
else:
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,37 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Arnassi Ruins area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionArnassiRuinsTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Arnassi Ruins area when option enabled"""
options = {
"no_progression_arnassi_ruins": False
}
unfillable_locations = [
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_RIGHT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_LEFT_PART,
AquariaLocationNames.ARNASSI_RUINS_BULB_IN_THE_CENTER_PART,
AquariaLocationNames.ARNASSI_RUINS_SONG_PLANT_SPORE,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_STATUE,
AquariaLocationNames.ARNASSI_RUINS_TRANSTURTLE,
AquariaLocationNames.ARNASSI_RUINS_CRAB_ARMOR,
]
def test_progression_arnassi_ruins(self) -> None:
"""
Unit test used to test that progression items can be put in Arnassi Ruins area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,37 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Energy Temple area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionEnergyTempleTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Energy Temple area when option enabled"""
options = {
"no_progression_energy_temple": False
}
unfillable_locations = [
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BEATING_THE_ENERGY_STATUE,
AquariaLocationNames.ENERGY_TEMPLE_FIRST_AREA_BULB_IN_THE_BOTTOM_ROOM_BLOCKED_BY_A_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_ENERGY_IDOL,
AquariaLocationNames.ENERGY_TEMPLE_SECOND_AREA_BULB_UNDER_THE_ROCK,
AquariaLocationNames.ENERGY_TEMPLE_BOTTOM_ENTRANCE_KROTITE_ARMOR,
AquariaLocationNames.ENERGY_TEMPLE_THIRD_AREA_BULB_IN_THE_BOTTOM_PATH,
AquariaLocationNames.ENERGY_TEMPLE_BOSS_AREA_FALLEN_GOD_TOOTH,
AquariaLocationNames.ENERGY_TEMPLE_BLASTER_ROOM_BLASTER_EGG,
]
def test_progression_energy_temple(self) -> None:
"""
Unit test used to test that progression items can be put in Energy Temple area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,37 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Frozen Veil area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionFrozenVeilTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Frozen Veil area when option enabled"""
options = {
"no_progression_frozen_veil": False
}
unfillable_locations = [
AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_ROOM_TO_THE_RIGHT,
AquariaLocationNames.ICE_CAVERN_FIRST_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_SECOND_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_THIRD_BULB_IN_THE_TOP_EXIT_ROOM,
AquariaLocationNames.ICE_CAVERN_BULB_IN_THE_LEFT_ROOM,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_LEFT_CAVE_WALL,
AquariaLocationNames.BUBBLE_CAVE_BULB_IN_THE_RIGHT_CAVE_WALL_BEHIND_THE_ICE_CRYSTAL,
AquariaLocationNames.BUBBLE_CAVE_VERSE_EGG,
]
def test_progression_frozen_veil(self) -> None:
"""
Unit test used to test that progression items can be put in Frozen Veil area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -8,7 +8,7 @@ from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class UNoProgressionHardHiddenTest(AquariaTestBase):
class ProgressionHardHiddenTest(AquariaTestBase):
"""Unit test used to test that no progression items can be put in hard or hidden locations when option disabled"""
options = {
"no_progression_hard_or_hidden_locations": False
@@ -42,7 +42,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
AquariaLocationNames.ARNASSI_RUINS_ARNASSI_ARMOR,
]
def test_unconfine_home_water_both_location_fillable(self) -> None:
def test_progression_hard_or_hidden(self) -> None:
"""Unit test used to test that progression items can be put in hard or hidden locations when option disabled"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:

View File

@@ -0,0 +1,54 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Kelp Forest when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionKelpForestTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Kelp Forest when option enabled"""
options = {
"no_progression_kelp_forest": False
}
unfillable_locations = [
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_BOTTOM_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_PATH_DOWN_FROM_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_IN_THE_TOP_LEFT_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_JELLY_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_BULB_CLOSE_TO_THE_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_LEFT_AREA_VERSE_EGG,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_UNDER_THE_ROCK_IN_THE_RIGHT_PATH,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_LEFT_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_BIG_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_LEFT_PATH_S_SMALL_ROOM,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_CENTER_CLEARING,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BLACK_PEARL,
AquariaLocationNames.KELP_FOREST_TOP_RIGHT_AREA_BULB_IN_THE_TOP_FISH_PASS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_BULB_CLOSE_TO_THE_SPIRIT_CRYSTALS,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_WALKER_BABY,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.KELP_FOREST_BOTTOM_RIGHT_AREA_ODD_CONTAINER,
AquariaLocationNames.KELP_FOREST_BOSS_AREA_BEATING_DRUNIAN_GOD,
AquariaLocationNames.KELP_FOREST_BOSS_ROOM_BULB_AT_THE_BOTTOM_OF_THE_AREA,
AquariaLocationNames.KELP_FOREST_BOTTOM_LEFT_AREA_FISH_CAVE_PUZZLE,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_BULB_IN_THE_SECOND_ROOM,
AquariaLocationNames.KELP_FOREST_SPRITE_CAVE_SEED_BAG,
AquariaLocationNames.MERMOG_CAVE_BULB_IN_THE_LEFT_PART_OF_THE_CAVE,
AquariaLocationNames.MERMOG_CAVE_PIRANHA_EGG,
]
def test_progression_kelp_forest(self) -> None:
"""
Unit test used to test that progression items can be put in Kelp Forest when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,31 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Mithalas area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionMithalasTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Mithalas area when option enabled"""
options = {
"no_progression_mithalas": False
}
unfillable_locations = [
AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS,
AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE,
]
def test_progression_mithalas(self) -> None:
"""
Unit test used to test that progression items can be put in Mithalas area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,31 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in Simon says area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionSimonSaysTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in Simon says area when option enabled"""
options = {
"no_progression_simon_says": False
}
unfillable_locations = [
AquariaLocationNames.SIMON_SAYS_AREA_BEATING_SIMON_SAYS,
AquariaLocationNames.SIMON_SAYS_AREA_TRANSTURTLE,
]
def test_progression_simon_says(self) -> None:
"""
Unit test used to test that progression items can be put in Simon says area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,60 @@
"""
Author: Louis M
Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in the Veil area when option enabled
"""
from . import AquariaTestBase
from ..Locations import AquariaLocationNames
class ProgressionVeilTest(AquariaTestBase):
"""Unit test used to test that progression items can be put in the Veil area when option enabled"""
options = {
"no_progression_veil": False
}
unfillable_locations = [
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_IN_LI_S_CAVE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_UNDER_THE_ROCK_IN_THE_TOP_RIGHT_PATH,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_HIDDEN_BEHIND_THE_BLOCKING_ROCK,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_TOP_LEFT_AREA_BULB_INSIDE_THE_FISH_PASS,
AquariaLocationNames.TURTLE_CAVE_TURTLE_EGG,
AquariaLocationNames.TURTLE_CAVE_BULB_IN_BUBBLE_CLIFF,
AquariaLocationNames.TURTLE_CAVE_URCHIN_COSTUME,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_IN_THE_MIDDLE_OF_THE_WALL_JUMP_CLIFF,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_GOLDEN_STARFISH,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_BULB_AT_THE_TOP_OF_THE_WATERFALL,
AquariaLocationNames.THE_VEIL_TOP_RIGHT_AREA_TRANSTURTLE,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_LEFT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_BULB_IN_THE_SPIRIT_PATH,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_VERSE_EGG,
AquariaLocationNames.THE_VEIL_BOTTOM_AREA_STONE_HEAD,
AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
AquariaLocationNames.OCTOPUS_CAVE_BULB_IN_THE_PATH_BELOW_THE_OCTOPUS_CAVE_PATH,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_LEFT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_TOP_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_AT_THE_TOP_OF_THE_HIGH_DARK_ROOM,
AquariaLocationNames.SUN_TEMPLE_GOLDEN_GEAR,
AquariaLocationNames.SUN_TEMPLE_FIRST_BULB_OF_THE_TEMPLE,
AquariaLocationNames.SUN_TEMPLE_BULB_ON_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_BULB_IN_THE_HIDDEN_ROOM_OF_THE_RIGHT_PART,
AquariaLocationNames.SUN_TEMPLE_SUN_KEY,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_PATH_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB,
AquariaLocationNames.SUN_TEMPLE_BOSS_AREA_BEATING_LUMEREAN_GOD,
]
def test_progression_veil(self) -> None:
"""
Unit test used to test that progression items can be put in the Veil area when option enabled
"""
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
self.assertTrue(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" cannot be filled with \"" + item_name + "\"")

View File

@@ -0,0 +1,30 @@
"""
Author: Louis M
Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the sun form with the goal four gods
"""
from . import AquariaTestBase
from ..Items import ItemNames
from ..Locations import AquariaLocationNames
from ..Options import UnconfineHomeWater, Objective
class SunFormFourGodsAccessTest(AquariaTestBase):
"""Unit test used to test accessibility of locations with and without the sun form with the goal four gods"""
options = {
"unconfine_home_water": UnconfineHomeWater.option_via_energy_door,
"objective": Objective.option_killing_the_four_gods
}
def test_sun_form_location(self) -> None:
"""Test locations that require sun form with the goal four gods"""
locations = [
AquariaLocationNames.OCTOPUS_CAVE_DUMBO_EGG,
AquariaLocationNames.BEATING_OCTOPUS_PRIME,
AquariaLocationNames.BEATING_THE_GOLEM,
AquariaLocationNames.SUNKEN_CITY_CLEARED
]
items = [[ItemNames.SUN_FORM]]
self.assertAccessDependency(locations, items, True)

View File

@@ -0,0 +1,29 @@
"""
Author: Louis M
Date: Sun, 06 Apr 2025 14:00:32 +0000
Description: Unit test used to test Sun Temple cliffs access
"""
from BaseClasses import CollectionState
from . import AquariaTestBase
from ..Items import ItemNames
from ..Options import UnconfineHomeWater
from ..Locations import AquariaLocationNames
class SunTempleCliffAccessTest(AquariaTestBase):
"""Unit test used to test Sun Temple cliffs access"""
options = {
"unconfine_home_water": UnconfineHomeWater.option_via_energy_door
}
def test_sun_temple_cliff_access(self) -> None:
"""test Sun Temple cliffs access"""
state = CollectionState(self.multiworld)
state.collect(self.get_item_by_name(ItemNames.BEAST_FORM))
state.collect(self.get_item_by_name(ItemNames.SUN_FORM))
first_cliff_location = self.multiworld.get_location(
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_FIRST_CLIFF_BULB, 1)
self.assertFalse(first_cliff_location.can_reach(state))
second_cliff_location = self.multiworld.get_location(
AquariaLocationNames.SUN_TEMPLE_BOSS_PATH_SECOND_CLIFF_BULB, 1)
self.assertFalse(second_cliff_location.can_reach(state))

View File

@@ -84,7 +84,7 @@ celeste_base_id: int = 0xCA10000
class CelesteItem(Item):
game = "Celeste"
game = "Celeste (Open World)"
class CelesteItemData(NamedTuple):
@@ -259,6 +259,7 @@ def generate_item_groups() -> dict[str, list[str]]:
"Blue Bubbles": [ItemName.blue_boosters],
"Red Bubbles": [ItemName.red_boosters],
"Touch Switches": [ItemName.coins],
"Shields": [ItemName.coins],
}
return item_groups

View File

@@ -16,7 +16,7 @@ celeste_base_id: int = 0xCA10000
class CelesteLocation(Location):
game = "Celeste"
game = "Celeste (Open World)"
class CelesteLocationData(NamedTuple):

View File

@@ -42,7 +42,7 @@ class CelesteOpenWorld(World):
options_dataclass = CelesteOptions
options: CelesteOptions
apworld_version = 10005
apworld_version = 10007
level_data: dict[str, Level] = load_logic_data()

View File

@@ -2,5 +2,5 @@
"game": "Celeste (Open World)",
"authors": [ "PoryGone" ],
"minimum_ap_version": "0.6.3",
"world_version": "1.0.5"
"world_version": "1.0.7"
}

View File

@@ -5413,13 +5413,13 @@
"name": "north-west",
"direction": "left",
"blocked": false,
"closes_behind": true
"closes_behind": false
},
{
"name": "west",
"direction": "left",
"blocked": false,
"closes_behind": true
"closes_behind": false
},
{
"name": "east",
@@ -14326,7 +14326,7 @@
"name": "golden",
"display_name": "Golden Strawberry",
"type": "golden_strawberry",
"rule": [ [ "blue_clouds", "pink_clouds", "blue_boosters", "move_blocks", "dash_refills", "springs", "coins" ] ]
"rule": [ [ "blue_clouds", "pink_clouds", "blue_boosters", "move_blocks", "moving_platforms", "dash_refills", "springs", "coins" ] ]
}
]
}
@@ -16985,7 +16985,7 @@
"name": "strawberry",
"display_name": "Strawberry",
"type": "strawberry",
"rule": [ [ "red_boosters" ] ]
"rule": []
}
]
}
@@ -17577,7 +17577,7 @@
"name": "strawberry",
"display_name": "Strawberry",
"type": "strawberry",
"rule": [ [ "red_boosters" ] ]
"rule": []
}
]
}
@@ -19953,7 +19953,7 @@
"name": "golden",
"display_name": "Golden Strawberry",
"type": "golden_strawberry",
"rule": [ [ "red_boosters", "swap_blocks", "dash_switches", "Entrance Key", "Depths Key", "Search Key 1", "Search Key 2", "seekers", "coins", "theo_crystal" ] ]
"rule": [ [ "red_boosters", "swap_blocks", "dash_switches", "dash_refills", "Entrance Key", "Depths Key", "Search Key 1", "Search Key 2", "seekers", "coins", "theo_crystal" ] ]
}
]
}
@@ -23479,7 +23479,7 @@
"name": "west",
"direction": "left",
"blocked": false,
"closes_behind": true
"closes_behind": false
},
{
"name": "top",
@@ -25250,7 +25250,7 @@
"name": "west",
"direction": "left",
"blocked": false,
"closes_behind": true
"closes_behind": false
},
{
"name": "east",
@@ -37237,13 +37237,13 @@
"name": "binoculars",
"display_name": "Binoculars",
"type": "binoculars",
"rule": [ [ "breaker_boxes" ] ]
"rule": [ [ "breaker_boxes", "double_dash_refills", "dash_switches" ] ]
},
{
"name": "key_2",
"display_name": "Power Source Key 2",
"type": "key",
"rule": [ [ "breaker_boxes", "double_dash_refills", "jellyfish" ] ]
"rule": [ [ "breaker_boxes", "double_dash_refills", "dash_switches", "jellyfish" ] ]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,8 @@ if __name__ == "__main__":
for level in level_data["levels"]:
level_str = (f" \"{level['name']}\": Level(\"{level['name']}\", "
f"\"{level['display_name']}\", "
f"[room for _, room in all_rooms.items() if room.level_name == \"{level['name']}\"], "
f"[room_con for _, room_con in all_room_connections.items() if room_con.level_name == \"{level['name']}\"]),"
f"rooms_by_level[\"{level['name']}\"], "
f"room_cons_by_level[\"{level['name']}\"]),"
)
all_levels.append(level_str)
@@ -31,8 +31,8 @@ if __name__ == "__main__":
room_str = (f" \"{room_full_name}\": Room(\"{level['name']}\", "
f"\"{room_full_name}\", \"{room_full_display_name}\", "
f"[reg for _, reg in all_regions.items() if reg.room_name == \"{room_full_name}\"], "
f"[door for _, door in all_doors.items() if door.room_name == \"{room_full_name}\"]"
f"regions_by_room[\"{room_full_name}\"], "
f"doors_by_room[\"{room_full_name}\"]"
)
if "checkpoint" in room and room["checkpoint"] != "":
@@ -47,8 +47,8 @@ if __name__ == "__main__":
region_str = (f" \"{region_full_name}\": PreRegion(\"{region_full_name}\", "
f"\"{room_full_name}\", "
f"[reg_con for _, reg_con in all_region_connections.items() if reg_con.source_name == \"{region_full_name}\"], "
f"[loc for _, loc in all_locations.items() if loc.region_name == \"{region_full_name}\"]),"
f"connections_by_region[\"{region_full_name}\"], "
f"locations_by_region[\"{region_full_name}\"]),"
)
all_regions.append(region_str)
@@ -150,6 +150,7 @@ if __name__ == "__main__":
print("")
print("from ..Levels import Level, Room, PreRegion, LevelLocation, RegionConnection, RoomConnection, Door, DoorDirection, LocationType")
print("from ..Names import ItemName")
print(f"from collections import defaultdict")
print("")
print("all_doors: dict[str, Door] = {")
for line in all_doors:
@@ -166,6 +167,15 @@ if __name__ == "__main__":
print(line)
print("}")
print("")
print("connections_by_region: defaultdict[str, list[RegionConnection]] = defaultdict(lambda: [])")
print("locations_by_region: defaultdict[str, list[LevelLocation]] = defaultdict(lambda: [])")
print("")
print("for _, connection in all_region_connections.items():")
print(" connections_by_region[connection.source_name].append(connection)")
print("")
print("for _, location in all_locations.items():")
print(" locations_by_region[location.region_name].append(location)")
print("")
print("all_regions: dict[str, PreRegion] = {")
for line in all_regions:
print(line)
@@ -176,11 +186,29 @@ if __name__ == "__main__":
print(line)
print("}")
print("")
print("regions_by_room: defaultdict[str, list[PreRegion]] = defaultdict(lambda: [])")
print("doors_by_room: defaultdict[str, list[Door]] = defaultdict(lambda: [])")
print("")
print("for _, region in all_regions.items():")
print(" regions_by_room[region.room_name].append(region)")
print("")
print("for _, door in all_doors.items():")
print(" doors_by_room[door.room_name].append(door)")
print("")
print("all_rooms: dict[str, Room] = {")
for line in all_rooms:
print(line)
print("}")
print("")
print("rooms_by_level: defaultdict[str, list[Room]] = defaultdict(lambda: [])")
print("room_cons_by_level: defaultdict[str, list[RoomConnection]] = defaultdict(lambda: [])")
print("")
print("for _, room in all_rooms.items():")
print(" rooms_by_level[room.level_name].append(room)")
print("")
print("for _, room_con in all_room_connections.items():")
print(" room_cons_by_level[room_con.level_name].append(room_con)")
print("")
print("all_levels: dict[str, Level] = {")
for line in all_levels:
print(line)

View File

@@ -218,10 +218,11 @@ class RandomizeEnemiesOption(DefaultOnToggle):
class SimpleEarlyBossesOption(DefaultOnToggle):
"""Avoid replacing Iudex Gundyr and Vordt with late bosses.
"""Avoid replacing Iudex Gundyr and Vordt with difficult bosses.
This excludes all bosses after Dancer of the Boreal Valley from these two boss fights. Disable
it for a chance at a much harder early game.
This limits these fights to bosses that are known to scale gracefully for low-level fights.
This doesn't necessarily mean that those bosses will be from the early game, just that they're
not too difficult when scaled down.
This is ignored unless enemies are randomized.
"""

516
worlds/earthbound/Client.py Normal file
View File

@@ -0,0 +1,516 @@
import logging
import struct
import typing
import time
import uuid
from struct import pack
from .game_data.local_data import client_specials, world_version, hint_bits, item_id_table, money_id_table
from .game_data.text_data import text_encoder
from .gifting.gift_tags import gift_properties
from .gifting.trait_parser import wanted_traits, trait_interpreter, gift_exclusions
from NetUtils import ClientStatus, color
from worlds.AutoSNIClient import SNIClient
if typing.TYPE_CHECKING:
from SNIClient import SNIContext
else:
SNIContext = typing.Any
snes_logger = logging.getLogger("SNES")
ROM_START = 0x000000
WRAM_START = 0xF50000
WRAM_SIZE = 0x20000
SRAM_START = 0xE00000
EB_ROMHASH_START = 0x00FFC0
WORLD_VERSION = 0x3FF0A0
ROMHASH_SIZE = 0x15
ITEM_MODE = ROM_START + 0x04FD76
ITEMQUEUE_HIGH = WRAM_START + 0xB576
ITEM_RECEIVED = WRAM_START + 0xB570
SPECIAL_RECEIVED = WRAM_START + 0xB572
MONEY_RECIVED = WRAM_START + 0xB5F1
SAVE_FILE = WRAM_START + 0xB4A1
GIYGAS_CLEAR = WRAM_START + 0x9C11
GAME_CLEAR = WRAM_START + 0x9C85
OPEN_WINDOW = WRAM_START + 0x8958
MELODY_TABLE = WRAM_START + 0x9C1E
EARTH_POWER_FLAG = WRAM_START + 0x9C82
CUR_SCENE = WRAM_START + 0x97B8
IS_IN_BATTLE = WRAM_START + 0x9643
DEATHLINK_ENABLED = ROM_START + 0x04FD74
DEATHLINK_TYPE = ROM_START + 0x04FD75
IS_CURRENTLY_DEAD = WRAM_START + 0xB582
GOT_DEATH_FROM_SERVER = WRAM_START + 0xB583
PLAYER_JUST_DIED_SEND_DEATHLINK = WRAM_START + 0xB584
IS_ABLE_TO_RECEIVE_DEATHLINKS = WRAM_START + 0xB585
CHAR_COUNT = WRAM_START + 0x98A4
OSS_FLAG = WRAM_START + 0x5D98
HINT_SCOUNT_IDS = ROM_START + 0x310250
SCOUTED_HINT_FLAGS = WRAM_START + 0xB621
MONEY_IN_BANK = WRAM_START + 0x9835
IS_ENERGYLINK_ENABLED = ROM_START + 0x04FD78
already_tried_to_connect = False
class EarthBoundClient(SNIClient):
game = "EarthBound"
patch_suffix = ".apeb"
most_recent_connect: str = ""
client_version: str = world_version
hint_list: list[int] = []
hinted_shop_locations: list[int] = []
async def deathlink_kill_player(self, ctx: "SNIContext") -> None:
from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read
battle_hp = {
1: WRAM_START + 0x9FBF,
2: WRAM_START + 0xA00D,
3: WRAM_START + 0xA05B,
4: WRAM_START + 0xA0A9,
}
active_hp = {
1: WRAM_START + 0x9A15,
2: WRAM_START + 0x9A74,
3: WRAM_START + 0x9AD3,
4: WRAM_START + 0x9B32,
}
scrolling_hp = {
1: WRAM_START + 0x9A13,
2: WRAM_START + 0x9A72,
3: WRAM_START + 0x9AD1,
4: WRAM_START + 0x9B30,
}
deathlink_mode = await snes_read(ctx, DEATHLINK_TYPE, 1)
oss_flag = await snes_read(ctx, OSS_FLAG, 1)
is_currently_dead = await snes_read(ctx, IS_CURRENTLY_DEAD, 1)
can_receive_deathlinks = await snes_read(ctx, IS_ABLE_TO_RECEIVE_DEATHLINKS, 1)
is_in_battle = await snes_read(ctx, IS_IN_BATTLE, 1)
char_count = await snes_read(ctx, CHAR_COUNT, 1)
snes_buffered_write(ctx, GOT_DEATH_FROM_SERVER, bytes([0x01]))
text_open = await snes_read(ctx, OPEN_WINDOW, 1)
if text_open is None: #Catch None reads from client jank????????
return
if is_currently_dead[0] != 0x00 or can_receive_deathlinks[0] == 0x00:
return
# If suppression is set and we're not in a battle dont do deathlinks
if oss_flag[0] != 0x00 and is_in_battle[0] == 0x00:
return
# Prevent overworld deaths while a menu is open
if not is_in_battle[0] and text_open[0] != 0xFF:
return
for i in range(char_count[0]):
w_cur_char = WRAM_START + 0x986F + i
current_char = await snes_read(ctx, w_cur_char, 1)
snes_buffered_write(ctx, active_hp[current_char[0]], bytes([0x00, 0x00]))
snes_buffered_write(ctx, battle_hp[i + 1], bytes([0x00, 0x00]))
if deathlink_mode[0] == 0 or is_in_battle[0] == 0:
# This should be the check for instant or mercy. Write the value, call it here
snes_buffered_write(ctx, scrolling_hp[current_char[0]], bytes([0x00, 0x00]))
await snes_flush_writes(ctx)
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
def on_package(self, ctx, cmd: str, args: dict[str, typing.Any]) -> None:
super().on_package(ctx, cmd, args)
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
async def validate_rom(self, ctx: "SNIContext") -> bool:
from SNIClient import snes_read
rom_name = await snes_read(ctx, EB_ROMHASH_START, ROMHASH_SIZE)
apworld_version = await snes_read(ctx, WORLD_VERSION, 16)
item_handling = await snes_read(ctx, ITEM_MODE, 1)
if rom_name is None or rom_name[:6] != b"MOM2AP":
return False
apworld_version = apworld_version.decode("utf-8").strip("\x00")
if apworld_version != self.most_recent_connect and apworld_version != self.client_version:
ctx.gui_error("Bad Version", f"EarthBound APWorld version {self.client_version} does not match generated version {apworld_version}")
self.most_recent_connect = apworld_version
return False
ctx.game = self.game
if item_handling[0] == 0x00:
ctx.items_handling = 0b001
else:
ctx.items_handling = 0b111
ctx.rom = rom_name
death_link = await snes_read(ctx, DEATHLINK_ENABLED, 1)
if death_link:
await ctx.update_death_link(bool(death_link[0] & 0b1))
return True
async def game_watcher(self, ctx: "SNIContext") -> None:
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read, snes_write
giygas_clear = await snes_read(ctx, GIYGAS_CLEAR, 0x1)
game_clear = await snes_read(ctx, GAME_CLEAR, 0x1)
item_received = await snes_read(ctx, ITEM_RECEIVED, 0x1)
special_received = await snes_read(ctx, SPECIAL_RECEIVED, 0x1)
money_received = await snes_read(ctx, MONEY_RECIVED, 0x2)
save_num = await snes_read(ctx, SAVE_FILE, 0x1)
text_open = await snes_read(ctx, OPEN_WINDOW, 1)
melody_table = await snes_read(ctx, MELODY_TABLE, 2)
earth_power_absorbed = await snes_read(ctx, EARTH_POWER_FLAG, 1)
cur_script = await snes_read(ctx, CUR_SCENE, 1)
rom = await snes_read(ctx, EB_ROMHASH_START, ROMHASH_SIZE)
scouted_hint_flags = await snes_read(ctx, SCOUTED_HINT_FLAGS, 1)
gift_target = await snes_read(ctx, WRAM_START + 0xB5E7, 2)
outbound_gifts = await snes_read(ctx, WRAM_START + 0x31D0, 1)
shop_scout = await snes_read(ctx, WRAM_START + 0x0770, 1)
shop_scouts_enabled = await snes_read(ctx, ROM_START + 0x04FD77, 1)
outgoing_energy = await snes_read(ctx, MONEY_IN_BANK, 4)
if rom != ctx.rom:
ctx.rom = None
return
if giygas_clear[0] & 0x01 == 0x01: # Are we in the epilogue
return
if save_num[0] == 0x00: # If on the title screen
return
if ctx.slot is None:
return
if outgoing_energy is None: #None Catcher
return
if f"GiftBoxes;{ctx.team}" not in ctx.stored_data:
await ctx.send_msgs([{
"cmd": "SetNotify",
"keys": [f"GiftBoxes;{ctx.team}"]
}])
# GIFTING DATA
if f"GiftBox;{ctx.team};{ctx.slot}" not in ctx.stored_data:
local_giftbox = {
str(ctx.slot): {
"is_open": True,
"accepts_any_gift": False,
"desired_traits": wanted_traits,
"minimum_gift_data_version": 2,
"maximum_gift_data_version": 3}}
await ctx.send_msgs([{
"cmd": "Set",
"key": f"GiftBoxes;{ctx.team}",
"want_reply": False,
"default": {},
"operations": [{"operation": "update", "value": local_giftbox}]
}])
await ctx.send_msgs([{
"cmd": "Get",
"keys": [f"GiftBox;{ctx.team};{ctx.slot}"]
}])
await ctx.send_msgs([{
"cmd": "SetNotify",
"keys": [f"GiftBox;{ctx.team};{ctx.slot}", f"GiftBoxes;{ctx.team}"]
}])
inbox = ctx.stored_data.get(f"GiftBox;{ctx.team};{ctx.slot}")
motherbox = ctx.stored_data.get(f"GiftBoxes;{ctx.team}")
if inbox:
gift_item_name = "None"
key, gift = next(iter(inbox.items()))
if "item_name" in gift or "ItemName" in gift:
gift_item_name = gift.get("item_name", gift.get("ItemName"))
if gift_item_name in item_id_table and gift_item_name not in gift_exclusions:
# If the name matches an EB item, convert it to one (even if not coming from EB)
item = item_id_table[gift_item_name]
else:
item = trait_interpreter(gift)
inbox_queue = await snes_read(ctx, WRAM_START + 0x3200, 1)
# Pause if the receiver queue is full
if not inbox_queue[0]:
await snes_write(ctx, [(WRAM_START + 0x3200, bytes([item]))])
inbox.pop(key)
await ctx.send_msgs([{
"cmd": "Set",
"key": f"GiftBox;{ctx.team};{ctx.slot}",
"want_reply": False,
"default": {},
"operations": [{"operation": "pop", "value": key}]
}])
# We're in the Gift selection menu. This should write the selected player's name into RAM
# for parsing.
# TODO; CHECK A SETNOTIFY HERE
gift_target = int.from_bytes(gift_target, byteorder="little")
# Giftbox checking for the gift menu UI
if gift_target != 0x00 and motherbox is not None:
gift_recipient = str(gift_target)
recip_name = ctx.player_names[gift_target]
recip_name = get_alias(recip_name, ctx.slot_info[gift_target].name)
recip_name = text_encoder(recip_name, 20)
if gift_recipient in motherbox:
if "IsOpen" in motherbox[gift_recipient]:
motherbox[gift_recipient]["is_open"] = motherbox[gift_recipient].pop("IsOpen")
if gift_recipient in motherbox and motherbox[gift_recipient]["is_open"]:
recip_name.extend(text_encoder(" (Open)", 20))
else:
recip_name.extend(text_encoder(" (Closed)", 20))
recip_name.append(0x00)
await snes_write(ctx, [(WRAM_START + 0xFF80, recip_name)])
await snes_write(ctx, [(WRAM_START + 0xB5E7, bytes([0x00, 0x00]))])
await snes_write(ctx, [(WRAM_START + 0xB573, bytes([0x00, 0x00]))])
gift_flag_byte = await snes_read(ctx, WRAM_START + 0xB622, 1)
gift_flag_byte = gift_flag_byte[0] | 0x04
await snes_write(ctx, [(WRAM_START + 0xB622, bytes([gift_flag_byte]))])
if outbound_gifts[0] != 0x00 and motherbox is not None:
gift_buffer = await snes_read(ctx, WRAM_START + 0x31D1, 3)
gift_item_id = gift_buffer[0]
gift = gift_properties[gift_item_id]
recipient = struct.unpack("H", gift_buffer[-2:])
if str(recipient[0]) in motherbox:
# Check if the player's box is open, refund if not
if "IsOpen" in motherbox[str(recipient[0])]:
# Does the recipient 0 thing work if > 255? Will need some testing.
motherbox[str(recipient[0])]["is_open"] = motherbox[str(recipient[0])].pop("IsOpen")
if "AcceptsAnyGift" in motherbox[str(recipient[0])]:
motherbox[str(recipient[0])]["accepts_any_gift"] = motherbox[str(recipient[0])].pop("AcceptsAnyGift")
if "DesiredTraits" in motherbox[str(recipient[0])]:
motherbox[str(recipient[0])]["desired_traits"] = motherbox[str(recipient[0])].pop("DesiredTraits")
if "Trait" in motherbox[str(recipient[0])]["desired_traits"]:
motherbox[str(recipient[0])]["desired_traits"]["trait"] = motherbox[str(recipient[0])]["desired_traits"].pop("Trait")
if str(recipient[0]) in motherbox and motherbox[str(recipient[0])]["is_open"] and (any(
motherbox[str(recipient[0])]["accepts_any_gift"] or
trait["trait"] in motherbox[str(recipient[0])]["desired_traits"] for trait in gift.traits)):
was_refunded = False
recipient = recipient[0]
else:
was_refunded = True
recipient = ctx.slot
guid = str(uuid.uuid4())
outgoing_gift = {
guid: {
"id": guid,
"item_name": gift.name,
"amount": 1,
"item_value": gift.value,
"traits": gift.traits,
"sender_slot": ctx.slot,
"receiver_slot": recipient,
"sender_team": ctx.team,
"receiver_team": ctx.team, # ??? Should be Receive slot team?
"is_refund": was_refunded}}
await ctx.send_msgs([{
"cmd": "Set",
"key": f"GiftBox;{ctx.team};{recipient}", # Receiver team here too
"want_reply": True,
"default": {},
"operations": [{"operation": "update", "value": outgoing_gift}]
}])
gift_queue = await snes_read(ctx, WRAM_START + 0x31D4, 0x21)
# shuffle the entire queue down 3 bytes
outbox_full_byte = await snes_read(ctx, WRAM_START + 0xB622, 1)
await snes_write(ctx, [(WRAM_START + 0x31D1, gift_queue)])
await snes_write(ctx, [(WRAM_START + 0x31D0, bytes([outbound_gifts[0] - 1]))])
outbox_full_byte = outbox_full_byte[0] & ~0x08
await snes_write(ctx, [(WRAM_START + 0xB622, bytes([outbox_full_byte]))])
if (game_clear[0] & 0x01 == 0x01) and not ctx.finished_game: # Goal should ignore the item queue and textbox check
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
for i in range(6):
if scouted_hint_flags[0] & hint_bits[i]:
if i not in self.hint_list:
scoutable_hint = await snes_read(ctx, HINT_SCOUNT_IDS + (i * 3), 3)
if not scoutable_hint[2]:
scoutable_hint = (int.from_bytes(scoutable_hint[:2], byteorder="little") + 0xEB0000)
self.hint_list.append(i)
await ctx.send_msgs([{"cmd": "CreateHints", "locations": [scoutable_hint], "player": ctx.player}])
else:
hint = self.slot_data['hint_man_hints'][i]
await ctx.send_msgs([{"cmd": "CreateHints", "locations": [hint[0]], "player": hint[1]}])
self.hint_list.append(i)
if shop_scout[0] and shop_scouts_enabled[0]:
shop_slots = []
for i in range(7):
slot_id = (0xEB0FF9 + (shop_scout[0] * 7) + i)
if slot_id in ctx.server_locations and slot_id not in self.hinted_shop_locations:
shop_slots.append(slot_id)
if shop_slots:
if shop_scouts_enabled[0] == 2:
await ctx.send_msgs([{"cmd": "CreateHints", "locations": shop_slots, "player": ctx.slot}])
await snes_write(ctx, [(WRAM_START + 0x0770, bytes([0x00]))])
else:
prog_shops = []
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": shop_slots, "create_as_hint": 0}])
for location in shop_slots:
if location in ctx.locations_info:
self.hinted_shop_locations.append(location)
if ctx.locations_info[location].flags & 0x01:
prog_shops.append(location)
if prog_shops:
await ctx.send_msgs([{"cmd": "CreateHints", "locations": prog_shops, "player": ctx.slot}])
melody_data = f"{ctx.team}_{ctx.slot}_melody_status"
earth_power_data = f"{ctx.team}_{ctx.slot}_earthpower"
current_melodies = int.from_bytes(melody_table, "little")
earth_power_state = int.from_bytes(earth_power_absorbed, "little")
if melody_data not in ctx.stored_data or (ctx.stored_data[melody_data] != current_melodies) or (ctx.stored_data[earth_power_data] != earth_power_state):
await ctx.send_msgs([{
"cmd": "Set",
"key": melody_data,
"default": None,
"want_reply": True,
"operations": [{"operation": "replace", "value": int.from_bytes(melody_table, "little")}]},
{
"cmd": "Set",
"key": earth_power_data,
"default": None,
"want_reply": True,
"operations": [{"operation": "replace", "value": int.from_bytes(earth_power_absorbed, "little")}]
}])
# death link handling goes here
if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time():
send_deathlink = await snes_read(ctx, PLAYER_JUST_DIED_SEND_DEATHLINK, 1)
currently_dead = send_deathlink[0] != 0x00
if send_deathlink[0] != 0x00:
snes_buffered_write(ctx, PLAYER_JUST_DIED_SEND_DEATHLINK, bytes([0x00]))
await ctx.handle_deathlink_state(currently_dead)
new_checks = []
from .game_data.local_data import check_table
location_ram_data = await snes_read(ctx, WRAM_START + 0x9C00, 0x88)
shop_location_flags = await snes_read(ctx, WRAM_START + 0xB721, 0x41)
for loc_id, loc_data in check_table.items():
if loc_id not in ctx.locations_checked:
if loc_id >= 0xEB1000:
data = shop_location_flags[loc_data[0]]
else:
data = location_ram_data[loc_data[0]]
masked_data = data & (1 << loc_data[1])
bit_set = masked_data != 0
invert_bit = ((len(loc_data) >= 3) and loc_data[2])
if bit_set != invert_bit and loc_id in ctx.server_locations:
if text_open[0] == 0xFF or shop_scout[0]: # Don't check locations while in a textbox
new_checks.append(loc_id)
for new_check_id in new_checks:
ctx.locations_checked.add(new_check_id)
location = ctx.location_names.lookup_in_slot(new_check_id)
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])
await snes_write(ctx, [(WRAM_START + 0x0770, bytes([0]))])
if item_received[0] or special_received[0] != 0x00 or money_received[0] != 0x00: # If processing any item from the server
return
is_energylink_enabled = await snes_read(ctx, IS_ENERGYLINK_ENABLED, 1)
is_requesting_energy = await snes_read(ctx, WRAM_START + 0x0790, 1)
energy_withdrawal = await snes_read(ctx, WRAM_START + 0x0796, 4)
ctx.set_notify(f"EnergyLink{ctx.team}")
energy = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0)
exchange_rate = 1000000
if is_energylink_enabled[0]:
deposited_energy = int.from_bytes(outgoing_energy, byteorder="little")
if deposited_energy:
deposited_energy *= exchange_rate
await snes_write(ctx, [(MONEY_IN_BANK, (0x00).to_bytes(4, byteorder="little"))])
await ctx.send_msgs([{
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
[{"operation": "add", "value": deposited_energy},
{"operation": "max", "value": 0}]}])
if is_requesting_energy[0] and energy: # This is just to pull the current number for a display.
energy //= exchange_rate
if energy > 9999999:
energy = 9999999
cap_flag = await snes_read(ctx, WRAM_START + 0xB623, 1)
cap_flag = int.from_bytes(cap_flag)
cap_flag |= 0x20
await snes_write(ctx, [(WRAM_START + 0xB623, cap_flag.to_bytes(1, byteorder="little"))])
await snes_write(ctx, [(WRAM_START + 0x0792, int(energy).to_bytes(4, byteorder="little"))])
await snes_write(ctx, [(WRAM_START + 0x0790, (0x00).to_bytes(1, byteorder="little"))])
if any(energy_withdrawal) and energy:
withdrawal = int.from_bytes(energy_withdrawal, byteorder="little")
withdrawal *= exchange_rate
energy = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0) # Refresh the value
if withdrawal > energy:
energy_success = 2
withdrawal = energy
else:
energy_success = 1
await snes_write(ctx, [(WRAM_START + 0x97D0, (withdrawal // exchange_rate).to_bytes(4, byteorder="little"))])
await snes_write(ctx, [(WRAM_START + 0x0796, (0x00).to_bytes(4, byteorder="little"))])
await ctx.send_msgs([{
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
[{"operation": "add", "value": (withdrawal * -1)},
{"operation": "max", "value": 0}]}])
await snes_write(ctx, [(WRAM_START + 0x079A, energy_success.to_bytes(1, byteorder="little"))]) # Signal the game to continue
if cur_script[0]: # Stop items during cutscenes
return
recv_count = await snes_read(ctx, ITEMQUEUE_HIGH, 2)
recv_index = struct.unpack("H", recv_count)[0]
if recv_index < len(ctx.items_received):
item = ctx.items_received[recv_index]
item_id = (item.item - 0xEB0000)
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_slot(item.item), "red", "bold"),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, ITEMQUEUE_HIGH, pack("H", recv_index))
if item_id <= 0xFD:
snes_buffered_write(ctx, WRAM_START + 0xB570, bytes([item_id]))
elif item_id in money_id_table:
snes_buffered_write(ctx, WRAM_START + 0xB5F1, bytes([list(money_id_table).index(item_id) + 1]))
else:
snes_buffered_write(ctx, WRAM_START + 0xB572, bytes([client_specials[item_id]]))
await snes_flush_writes(ctx)
def get_alias(alias: str, slot_name: str) -> str:
try:
index = alias.index(f" ({slot_name}")
except ValueError:
return alias
return alias[:index]

337
worlds/earthbound/Items.py Normal file
View File

@@ -0,0 +1,337 @@
from typing import Dict, Set, NamedTuple, Optional
from BaseClasses import ItemClassification
class ItemData(NamedTuple):
category: str
code: Optional[int]
classification: ItemClassification
amount: int = 1
item_table: Dict[str, ItemData] = {
"Franklin Badge": ItemData("Key Items", 0xEB0001, ItemClassification.progression),
"Teddy Bear": ItemData("Characters", 0xEB0002, ItemClassification.filler, 0),
"Super Plush Bear": ItemData("Characters", 0xEB0003, ItemClassification.useful, 0),
"Broken Machine": ItemData("Broken Items", 0xEB0004, ItemClassification.useful, 0),
"Broken Gadget": ItemData("Jeff Weapons", 0xEB0005, ItemClassification.useful, 0),
"Broken Air Gun": ItemData("Jeff Weapons", 0xEB0006, ItemClassification.filler, 0),
"Broken Spray Can": ItemData("Broken Items", 0xEB0007, ItemClassification.filler, 0),
"Broken Laser": ItemData("Jeff Weapons", 0xEB0008, ItemClassification.useful, 0),
"Broken Iron": ItemData("Broken Items", 0xEB0009, ItemClassification.filler, 0),
"Broken Pipe": ItemData("Broken Items", 0xEB000A, ItemClassification.useful, 0),
"Broken Cannon": ItemData("Jeff Weapons", 0xEB000B, ItemClassification.useful, 0),
"Broken Tube": ItemData("Broken Items", 0xEB000C, ItemClassification.useful, 0),
"Broken Bazooka": ItemData("Broken Items", 0xEB000D, ItemClassification.useful, 0),
"Broken Trumpet": ItemData("Broken Items", 0xEB000E, ItemClassification.filler, 0),
"Broken Harmonica": ItemData("Jeff Weapons", 0xEB000F, ItemClassification.useful, 0),
"Broken Antenna": ItemData("Jeff Weapons", 0xEB0010, ItemClassification.useful, 0),
"Cracked Bat": ItemData("Ness Weapons", 0xEB0011, ItemClassification.filler, 0),
"Tee Ball Bat": ItemData("Ness Weapons", 0xEB0012, ItemClassification.filler, 0),
"Sand Lot Bat": ItemData("Ness Weapons", 0xEB0013, ItemClassification.filler, 0),
"Minor League Bat": ItemData("Ness Weapons", 0xEB0014, ItemClassification.filler, 0),
"Mr. Baseball Bat": ItemData("Ness Weapons", 0xEB0015, ItemClassification.useful, 0),
"Big League Bat": ItemData("Ness Weapons", 0xEB00D5, ItemClassification.useful, 0),
"Hall of Fame Bat": ItemData("Ness Weapons", 0xEB0017, ItemClassification.useful, 0),
"Magicant Bat": ItemData("Ness Weapons", 0xEB0018, ItemClassification.useful),
"Legendary Bat": ItemData("Ness Weapons", 0xEB0019, ItemClassification.useful),
"Gutsy Bat": ItemData("Ness Weapons", 0xEB001A, ItemClassification.useful, 0),
"Casey Bat": ItemData("Ness Weapons", 0xEB001B, ItemClassification.filler, 0),
"Fry Pan": ItemData("Paula Weapons", 0xEB001C, ItemClassification.filler, 0),
"Thick Fry Pan": ItemData("Paula Weapons", 0xEB001D, ItemClassification.filler, 0),
"Deluxe Fry Pan": ItemData("Paula Weapons", 0xEB001E, ItemClassification.filler, 0),
"Chef's Fry Pan": ItemData("Paula Weapons", 0xEB001F, ItemClassification.useful, 0),
"French Fry Pan": ItemData("Paula Weapons", 0xEB0020, ItemClassification.useful, 0),
"Magic Fry Pan": ItemData("Paula Weapons", 0xEB0021, ItemClassification.useful, 0),
"Holy Fry Pan": ItemData("Paula Weapons", 0xEB0022, ItemClassification.useful, 0),
"Sword of Kings": ItemData("Poo Weapons", 0xEB0023, ItemClassification.useful, 0),
"Pop Gun": ItemData("Jeff Weapons", 0xEB0024, ItemClassification.filler),
"Stun Gun": ItemData("Jeff Weapons", 0xEB0025, ItemClassification.filler),
"Toy Air Gun": ItemData("Jeff Weapons", 0xEB0026, ItemClassification.filler, 0),
"Magnum Air Gun": ItemData("Jeff Weapons", 0xEB0027, ItemClassification.filler, 0),
"Zip Gun": ItemData("Jeff Weapons", 0xEB0028, ItemClassification.filler, 0),
"Laser Gun": ItemData("Jeff Weapons", 0xEB0029, ItemClassification.filler, 0),
"Hyper Beam": ItemData("Jeff Weapons", 0xEB002A, ItemClassification.useful, 0),
"Crusher Beam": ItemData("Jeff Weapons", 0xEB002B, ItemClassification.useful, 0),
"Spectrum Beam": ItemData("Jeff Weapons", 0xEB002C, ItemClassification.useful, 0),
"Death Ray": ItemData("Jeff Weapons", 0xEB002D, ItemClassification.useful),
"Baddest Beam": ItemData("Jeff Weapons", 0xEB002E, ItemClassification.useful, 0),
"Moon Beam Gun": ItemData("Jeff Weapons", 0xEB002F, ItemClassification.useful),
"Gaia Beam": ItemData("Jeff Weapons", 0xEB0030, ItemClassification.useful, 0),
"Yo-yo": ItemData("Alt Weapons", 0xEB0031, ItemClassification.filler, 0),
"Slingshot": ItemData("Alt Weapons", 0xEB0032, ItemClassification.filler, 0),
"Bionic Slingshot": ItemData("Alt Weapons", 0xEB0033, ItemClassification.filler, 0),
"Trick Yo-yo": ItemData("Alt Weapons", 0xEB0034, ItemClassification.filler, 0),
"Combat Yo-yo": ItemData("Alt Weapons", 0xEB0035, ItemClassification.filler, 0),
"Travel Charm": ItemData("Body Equipment", 0xEB0036, ItemClassification.filler),
"Great Charm": ItemData("Body Equipment", 0xEB0037, ItemClassification.filler),
"Crystal Charm": ItemData("Body Equipment", 0xEB0038, ItemClassification.filler, 0),
"Rabbit's Foot": ItemData("Body Equipment", 0xEB0039, ItemClassification.useful),
"Flame Pendant": ItemData("Body Equipment", 0xEB003A, ItemClassification.useful),
"Rain Pendant": ItemData("Body Equipment", 0xEB003B, ItemClassification.useful),
"Night Pendant": ItemData("Body Equipment", 0xEB003C, ItemClassification.useful),
"Sea Pendant": ItemData("Body Equipment", 0xEB003D, ItemClassification.useful),
"Star Pendant": ItemData("Body Equipment", 0xEB003E, ItemClassification.useful, 0),
"Cloak of Kings": ItemData("Poo Equipment", 0xEB003F, ItemClassification.useful),
"Cheap Bracelet": ItemData("Arm Equipment", 0xEB0040, ItemClassification.filler, 0),
"Copper Bracelet": ItemData("Arm Equipment", 0xEB0041, ItemClassification.filler, 0),
"Silver Bracelet": ItemData("Arm Equipment", 0xEB0042, ItemClassification.filler, 0),
"Gold Bracelet": ItemData("Arm Equipment", 0xEB0043, ItemClassification.filler, 0),
"Platinum Band": ItemData("Arm Equipment", 0xEB00D8, ItemClassification.useful),
"Diamond Band": ItemData("Arm Equipment", 0xEB00D9, ItemClassification.useful),
"Pixie's Bracelet": ItemData("Arm Equipment", 0xEB0046, ItemClassification.useful),
"Cherub's Band": ItemData("Arm Equipment", 0xEB0047, ItemClassification.useful),
"Goddess Band": ItemData("Arm Equipment", 0xEB0048, ItemClassification.useful),
"Bracer of Kings": ItemData("Poo Equipment", 0xEB0049, ItemClassification.useful),
"Baseball Cap": ItemData("Other Equipment", 0xEB004A, ItemClassification.filler, 0),
"Holmes Hat": ItemData("Other Equipment", 0xEB004B, ItemClassification.filler, 0),
"Mr. Baseball Cap": ItemData("Other Equipment", 0xEB004C, ItemClassification.filler, 0),
"Hard Hat": ItemData("Other Equipment", 0xEB004D, ItemClassification.filler, 0),
"Ribbon": ItemData("Ribbons", 0xEB004E, ItemClassification.filler, 0),
"Red Ribbon": ItemData("Ribbons", 0xEB004F, ItemClassification.filler, 0),
"Goddess Ribbon": ItemData("Ribbons", 0xEB0050, ItemClassification.useful, 0),
"Coin of Slumber": ItemData("Other Equipment", 0xEB0051, ItemClassification.useful),
"Coin of Defense": ItemData("Other Equipment", 0xEB0052, ItemClassification.useful, 0),
"Lucky Coin": ItemData("Other Equipment", 0xEB0053, ItemClassification.useful, 0),
"Talisman Coin": ItemData("Other Equipment", 0xEB0054, ItemClassification.useful, 0),
"Shiny Coin": ItemData("Other Equipment", 0xEB0055, ItemClassification.useful, 0),
"Souvenir Coin": ItemData("Other Equipment", 0xEB0056, ItemClassification.useful),
"Diadem of Kings": ItemData("Poo Equipment", 0xEB0057, ItemClassification.useful),
"Cookie": ItemData("Food", 0xEB0058, ItemClassification.filler, 0),
"Bag of Fries": ItemData("Food", 0xEB0059, ItemClassification.filler, 0),
"Hamburger": ItemData("Food", 0xEB005A, ItemClassification.filler, 0),
"Boiled Egg": ItemData("Food", 0xEB005B, ItemClassification.filler, 0),
"Fresh Egg": ItemData("Food", 0xEB005C, ItemClassification.filler, 0),
"Picnic Lunch": ItemData("Food", 0xEB005D, ItemClassification.filler, 0),
"Pasta di Summers": ItemData("Food", 0xEB005E, ItemClassification.filler, 0),
"Pizza": ItemData("Food", 0xEB005F, ItemClassification.filler, 0),
"Chef's Special": ItemData("Food", 0xEB0060, ItemClassification.filler, 0),
"Large Pizza": ItemData("Food", 0xEB0061, ItemClassification.filler, 0),
"PSI Caramel": ItemData("Food", 0xEB0062, ItemClassification.useful, 0),
"Magic Truffle": ItemData("Food", 0xEB0063, ItemClassification.useful, 0),
"Brain Food Lunch": ItemData("Food", 0xEB0064, ItemClassification.useful, 0),
"Rock Candy": ItemData("Food", 0xEB0065, ItemClassification.useful, 0),
"Croissant": ItemData("Food", 0xEB0066, ItemClassification.filler, 0),
"Bread Roll": ItemData("Food", 0xEB0067, ItemClassification.filler, 0),
"Pak of Bubble Gum": ItemData("Key Items", 0xEB0068, ItemClassification.progression),
"Jar of Fly Honey": ItemData("Key Items", 0xEB0069, ItemClassification.progression),
"Can of Fruit Juice": ItemData("Food", 0xEB006A, ItemClassification.filler, 0),
"Royal Iced Tea": ItemData("Food", 0xEB006B, ItemClassification.filler, 0),
"Protein Drink": ItemData("Food", 0xEB006C, ItemClassification.filler, 0),
"Kraken Soup": ItemData("Food", 0xEB006D, ItemClassification.filler, 0),
"Bottle of Water": ItemData("Food", 0xEB006E, ItemClassification.filler, 0),
"Cold Remedy": ItemData("Status Heal", 0xEB006F, ItemClassification.filler, 0),
"Vial of Serum": ItemData("Status Heal", 0xEB0070, ItemClassification.filler, 0),
"IQ Capsule": ItemData("Food", 0xEB0071, ItemClassification.useful, 0),
"Guts Capsule": ItemData("Food", 0xEB0072, ItemClassification.useful, 0),
"Speed Capsule": ItemData("Food", 0xEB0073, ItemClassification.useful, 0),
"Vital Capsule": ItemData("Food", 0xEB0074, ItemClassification.useful, 0),
"Luck Capsule": ItemData("Food", 0xEB0075, ItemClassification.useful, 0),
"Ketchup Packet": ItemData("Condiments", 0xEB0076, ItemClassification.filler, 0),
"Sugar Packet": ItemData("Condiments", 0xEB0077, ItemClassification.filler, 0),
"Tin of Cocoa": ItemData("Condiments", 0xEB0078, ItemClassification.filler, 0),
"Carton of Cream": ItemData("Condiments", 0xEB0079, ItemClassification.filler, 0),
"Sprig of Parsley": ItemData("Condiments", 0xEB007A, ItemClassification.filler, 0),
"Jar of Hot Sauce": ItemData("Condiments", 0xEB007B, ItemClassification.filler, 0),
"Salt Packet": ItemData("Condiments", 0xEB007C, ItemClassification.filler, 0),
"Tiny Key": ItemData("Key Items", 0xEB007D, ItemClassification.progression), # Progressive Gun
"Jar of Delisauce": ItemData("Condiments", 0xEB007E, ItemClassification.useful, 0),
"Wet Towel": ItemData("Status Heal", 0xEB007F, ItemClassification.filler, 0),
"Refreshing Herb": ItemData("Status Heal", 0xEB0080, ItemClassification.useful, 0),
"Secret Herb": ItemData("Status Heal", 0xEB0081, ItemClassification.useful, 0),
"Horn of Life": ItemData("Status Heal", 0xEB0082, ItemClassification.useful, 0),
"Counter-PSI Unit": ItemData("Jeff Items", 0xEB0083, ItemClassification.useful, 0),
"Shield Killer": ItemData("Jeff Items", 0xEB0084, ItemClassification.useful, 0),
"Bazooka": ItemData("Jeff Items", 0xEB0085, ItemClassification.useful, 0),
"Heavy Bazooka": ItemData("Jeff Items", 0xEB0086, ItemClassification.useful, 0),
"HP-Sucker": ItemData("Jeff Items", 0xEB0087, ItemClassification.useful),
"Hungry HP-Sucker": ItemData("Jeff Items", 0xEB0088, ItemClassification.useful, 0),
"Xterminator Spray": ItemData("Battle Items", 0xEB0089, ItemClassification.useful, 0),
"Slime Generator": ItemData("Jeff Items", 0xEB008A, ItemClassification.useful, 0),
"Yogurt Dispenser": ItemData("Key Items", 0xEB008B, ItemClassification.progression),
"Ruler": ItemData("Battle Items", 0xEB008C, ItemClassification.filler, 0),
"Snake Bag": ItemData("Battle Items", 0xEB008D, ItemClassification.filler, 0),
"Mummy Wrap": ItemData("Battle Items", 0xEB008E, ItemClassification.filler, 0),
"Protractor": ItemData("Battle Items", 0xEB008F, ItemClassification.filler, 0),
"Bottle Rocket": ItemData("Jeff Items", 0xEB0090, ItemClassification.filler, 0),
"Big Bottle Rocket": ItemData("Jeff Items", 0xEB0091, ItemClassification.useful, 0),
"Multi Bottle Rocket": ItemData("Jeff Items", 0xEB0092, ItemClassification.useful, 0),
"Bomb": ItemData("Battle Items", 0xEB0093, ItemClassification.filler, 0),
"Super Bomb": ItemData("Battle Items", 0xEB0094, ItemClassification.useful, 0),
"Insecticide Spray": ItemData("Battle Items", 0xEB0095, ItemClassification.filler, 0),
"Rust Promoter": ItemData("Battle Items", 0xEB0096, ItemClassification.filler, 0),
"Rust Promoter DX": ItemData("Battle Items", 0xEB0097, ItemClassification.useful, 0),
"Pair of Dirty Socks": ItemData("Battle Items", 0xEB0098, ItemClassification.filler, 0),
"Stag Beetle": ItemData("Battle Items", 0xEB0099, ItemClassification.filler, 0),
"Toothbrush": ItemData("Battle Items", 0xEB009A, ItemClassification.filler, 0),
"Handbag Strap": ItemData("Battle Items", 0xEB009B, ItemClassification.filler, 0),
"Pharaoh's Curse": ItemData("Battle Items", 0xEB009C, ItemClassification.filler, 0),
"Defense Shower": ItemData("Battle Items", 0xEB009D, ItemClassification.useful, 0),
"UFO Engine": ItemData("Key Items", 0xEB009E, ItemClassification.progression),
"Sudden Guts Pill": ItemData("Battle Items", 0xEB009F, ItemClassification.useful, 0),
"Bag of Dragonite": ItemData("Battle Items", 0xEB00A0, ItemClassification.useful, 0),
"Defense Spray": ItemData("Battle Items", 0xEB00A1, ItemClassification.filler, 0),
"Piggy Nose": ItemData("Key Items", 0xEB00A2, ItemClassification.progression),
"For Sale Sign": ItemData("Field Items", 0xEB00A3, ItemClassification.filler),
"Shyness Book": ItemData("Key Items", 0xEB00A4, ItemClassification.progression),
"Picture Postcard": ItemData("Field Items", 0xEB00A5, ItemClassification.filler, 0),
"King Banana": ItemData("Key Items", 0xEB00A6, ItemClassification.progression),
"Letter For Tony": ItemData("Key Items", 0xEB00A7, ItemClassification.progression),
"Chick": ItemData("Field Items", 0xEB00A8, ItemClassification.filler, 0),
"Chicken": ItemData("Field Items", 0xEB00A9, ItemClassification.filler, 0),
"Key to the Shack": ItemData("Key Items", 0xEB00AA, ItemClassification.progression),
"Key to the Cabin": ItemData("Key Items", 0xEB00AB, ItemClassification.progression),
"Bad Key Machine": ItemData("Key Items", 0xEB00AC, ItemClassification.progression),
# "Archipelago Item": ItemData("Key Items", 0xEB00AD, ItemClassification.progression, 0),
"Zombie Paper": ItemData("Key Items", 0xEB00AE, ItemClassification.progression),
"Hawk Eye": ItemData("Key Items", 0xEB00AF, ItemClassification.progression),
"Bicycle": ItemData("Key Items", 0xEB00B0, ItemClassification.useful),
"ATM Card": ItemData("Key Items", 0xEB00B1, ItemClassification.progression, 0),
"Show Ticket": ItemData("Key Items", 0xEB00B2, ItemClassification.filler, 0),
"Tenda Lavapants": ItemData("Key Items", 0xEB00B3, ItemClassification.progression), # Progressive Bat
"Wad of Bills": ItemData("Key Items", 0xEB00B4, ItemClassification.progression),
"Warp Pad": ItemData("Key Items", 0xEB00B5, ItemClassification.progression, 0),
"Diamond": ItemData("Key Items", 0xEB00B6, ItemClassification.progression),
"Signed Banana": ItemData("Key Items", 0xEB00B7, ItemClassification.progression),
"Pencil Eraser": ItemData("Key Items", 0xEB00B8, ItemClassification.progression),
"Hieroglyph Copy": ItemData("Key Items", 0xEB00B9, ItemClassification.progression),
"Meteotite": ItemData("Field Items", 0xEB00BA, ItemClassification.useful, 0),
"Contact Lens": ItemData("Key Items", 0xEB00BB, ItemClassification.progression),
"Hand-Aid": ItemData("Food", 0xEB00BC, ItemClassification.useful),
"Trout Yogurt": ItemData("Food", 0xEB00BD, ItemClassification.filler, 0),
"Banana": ItemData("Food", 0xEB00BE, ItemClassification.filler, 0),
"Calorie Stick": ItemData("Food", 0xEB00BF, ItemClassification.filler, 0),
"Key to the Tower": ItemData("Key Items", 0xEB00C0, ItemClassification.progression),
"Meteorite Piece": ItemData("Key Items", 0xEB00C1, ItemClassification.progression),
"Earth Pendant": ItemData("Body Equipment", 0xEB00C2, ItemClassification.useful, 0),
"Neutralizer": ItemData("Jeff Items", 0xEB00C3, ItemClassification.useful),
"Sound Stone": ItemData("Key Items", 0xEB00C4, ItemClassification.progression, 0),
"Exit Mouse": ItemData("Key Items", 0xEB00C5, ItemClassification.useful, 0),
"Gelato de Resort": ItemData("Food", 0xEB00C6, ItemClassification.filler, 0),
"Snake": ItemData("Battle Items", 0xEB00C7, ItemClassification.filler, 0),
"Viper": ItemData("Battle Items", 0xEB00C8, ItemClassification.filler, 0),
"Brain Stone": ItemData("Battle Items", 0xEB00C9, ItemClassification.filler),
"Police Badge": ItemData("Key Items", 0xEB00CA, ItemClassification.progression),
"Mining Permit": ItemData("Key Items", 0xEB00CB, ItemClassification.progression),
"Suporma": ItemData("Field Items", 0xEB00CC, ItemClassification.trap),
"Key to the Locker": ItemData("Key Items", 0xEB00CD, ItemClassification.progression),
"Insignificant Item": ItemData("Key Items", 0xEB00CE, ItemClassification.progression),
"Magic Tart": ItemData("Food", 0xEB00CF, ItemClassification.useful, 0),
"Tiny Ruby": ItemData("Key Items", 0xEB00D0, ItemClassification.progression),
"Monkey's Love": ItemData("Battle Items", 0xEB00D1, ItemClassification.useful),
"Eraser Eraser": ItemData("Key Items", 0xEB00D2, ItemClassification.progression),
"Tendakraut": ItemData("Key Items", 0xEB00D3, ItemClassification.progression),
"T-Rex's Bat": ItemData("Ness Weapons", 0xEB00D4, ItemClassification.useful, 0),
# "Big League Bat": ItemData("Ness Weapons", 0xEB0016, ItemClassification.useful, 0), Summers copy
"Ultimate Bat": ItemData("Ness Weapons", 0xEB00D6, ItemClassification.useful, 0),
"Double Beam": ItemData("Jeff Weapons", 0xEB00D7, ItemClassification.useful, 0),
# "Platinum Band": ItemData("Arm Equipment", 0xEB00D8, ItemClassification.useful, 0), Summers copy
# "Diamond Band": ItemData("Arm Equipment", 0xEB00D9, ItemClassification.useful, 0), Summers Copy
"Defense Ribbon": ItemData("Ribbons", 0xEB00DA, ItemClassification.useful, 0),
"Talisman Ribbon": ItemData("Ribbons", 0xEB00DB, ItemClassification.useful),
"Saturn Ribbon": ItemData("Ribbons", 0xEB00DC, ItemClassification.useful),
"Coin of Silence": ItemData("Other Equipment", 0xEB00DD, ItemClassification.useful, 0),
"Charm Coin": ItemData("Other Equipment", 0xEB00DE, ItemClassification.useful, 0),
"Cup of Noodles": ItemData("Food", 0xEB00DF, ItemClassification.filler, 0),
"Repel Sandwich": ItemData("Food", 0xEB00E0, ItemClassification.useful, 0),
"Repel Superwich": ItemData("Food", 0xEB00E1, ItemClassification.useful, 0),
"Lucky Sandwich": ItemData("Food", 0xEB00E2, ItemClassification.useful, 0),
"Progressive Bat": ItemData("Progressive Equipment", 0xEB00E3, ItemClassification.useful, 0),
"Progressive Fry Pan": ItemData("Progressive Equipment", 0xEB00E4, ItemClassification.useful, 0),
"Progressive Gun": ItemData("Progressive Equipment", 0xEB00E5, ItemClassification.useful, 0),
"Progressive Bracelet": ItemData("Progressive Equipment", 0xEB00E6, ItemClassification.useful, 0),
"Progressive Other": ItemData("Progressive Equipment", 0xEB00E7, ItemClassification.useful, 0),
"Cup of Coffee": ItemData("Food", 0xEB00E8, ItemClassification.filler, 0),
"Double Burger": ItemData("Food", 0xEB00E9, ItemClassification.filler, 0),
"Peanut Cheese Bar": ItemData("Food", 0xEB00EA, ItemClassification.filler, 0),
"Piggy Jelly": ItemData("Food", 0xEB00EB, ItemClassification.filler, 0),
"Bowl of Rice Gruel": ItemData("Food", 0xEB00EC, ItemClassification.filler, 0),
"Bean Croquette": ItemData("Food", 0xEB00ED, ItemClassification.filler, 0),
"Molokheiya Soup": ItemData("Food", 0xEB00EE, ItemClassification.filler, 0),
"Plain Roll": ItemData("Food", 0xEB00EF, ItemClassification.filler, 0),
"Kabob": ItemData("Food", 0xEB00F0, ItemClassification.filler, 0),
"Plain Yogurt": ItemData("Food", 0xEB00F1, ItemClassification.filler, 0),
"Beef Jerky": ItemData("Food", 0xEB00F2, ItemClassification.filler, 0),
"Mammoth Burger": ItemData("Food", 0xEB00F3, ItemClassification.filler, 0),
"Spicy Jerky": ItemData("Food", 0xEB00F4, ItemClassification.filler, 0),
"Luxury Jerky": ItemData("Food", 0xEB00F5, ItemClassification.filler, 0),
"Bottle of DXwater": ItemData("Food", 0xEB00F6, ItemClassification.useful, 0),
"Magic Pudding": ItemData("Food", 0xEB00F7, ItemClassification.useful, 0),
"Non-Stick Frypan": ItemData("Paula Weapons", 0xEB00F8, ItemClassification.useful, 0),
"Mr. Saturn Coin": ItemData("Other Equipment", 0xEB00F9, ItemClassification.useful),
"Meteornium": ItemData("Field Items", 0xEB00FA, ItemClassification.useful, 0),
"Popsicle": ItemData("Food", 0xEB00FB, ItemClassification.filler, 0),
"Cup of Lifenoodles": ItemData("Status Heal", 0xEB00FC, ItemClassification.useful, 0),
"Carrot Key": ItemData("Key Items", 0xEB00FD, ItemClassification.progression),
"Onett Teleport": ItemData("PSI", 0xEB00FE, ItemClassification.progression),
"Twoson Teleport": ItemData("PSI", 0xEB00FF, ItemClassification.progression),
"Happy-Happy Village Teleport": ItemData("PSI", 0xEB0100, ItemClassification.progression),
"Threed Teleport": ItemData("PSI", 0xEB0101, ItemClassification.progression),
"Saturn Valley Teleport": ItemData("PSI", 0xEB0102, ItemClassification.progression),
"Dusty Dunes Teleport": ItemData("PSI", 0xEB0103, ItemClassification.progression),
"Fourside Teleport": ItemData("PSI", 0xEB0104, ItemClassification.progression),
"Winters Teleport": ItemData("PSI", 0xEB0105, ItemClassification.progression),
"Summers Teleport": ItemData("PSI", 0xEB0106, ItemClassification.progression),
"Scaraba Teleport": ItemData("PSI", 0xEB0107, ItemClassification.progression),
"Dalaam Teleport": ItemData("PSI", 0xEB0108, ItemClassification.progression),
"Deep Darkness Teleport": ItemData("PSI", 0xEB0109, ItemClassification.progression),
"Tenda Village Teleport": ItemData("PSI", 0xEB010A, ItemClassification.progression),
"Lost Underworld Teleport": ItemData("PSI", 0xEB010B, ItemClassification.progression),
"Progressive Poo PSI": ItemData("PSI", 0xEB010C, ItemClassification.useful, 2),
"Magicant Teleport": ItemData("PSI", 0xEB010D, ItemClassification.progression),
"Paula": ItemData("Characters", 0xEB010E, ItemClassification.progression),
"Jeff": ItemData("Characters", 0xEB010F, ItemClassification.progression),
"Poo": ItemData("Characters", 0xEB0110, ItemClassification.progression),
"Flying Man": ItemData("Characters", 0xEB0111, ItemClassification.useful),
"Ness": ItemData("Characters", 0xEB0112, ItemClassification.progression),
"Photograph": ItemData("Photos", 0xEB0113, ItemClassification.trap, 0),
"$10": ItemData("Money", 0xEB0114, ItemClassification.filler, 0),
"$100": ItemData("Money", 0xEB0115, ItemClassification.filler, 0),
"$1000": ItemData("Money", 0xEB0116, ItemClassification.useful, 0),
'Threed Tunnels Clear': ItemData('Events', None, ItemClassification.progression, 0),
'Submarine to Deep Darkness': ItemData('Events', None, ItemClassification.progression, 0),
'Melody': ItemData('Events', None, ItemClassification.progression, 0),
'Saved Earth': ItemData('Events', None, ItemClassification.progression, 0),
"Power of the Earth": ItemData("Events", None, ItemClassification.progression, 0),
"Alternate Goal": ItemData("Events", None, ItemClassification.useful, 0),
"Valley Bridge Repair": ItemData("Events", None, ItemClassification.progression, 0),
"Magicant Unlock": ItemData("Events", None, ItemClassification.progression, 0),
"ATM Access": ItemData("Events", None, ItemClassification.progression, 0)
}
def get_item_names_per_category() -> Dict[str, Set[str]]:
categories: Dict[str, Set[str]] = {}
for name, data in item_table.items():
if data.category != "Events":
categories.setdefault(data.category, set()).add(name)
return categories

View File

@@ -0,0 +1,594 @@
from typing import List, Optional, NamedTuple, TYPE_CHECKING
from .Options import MagicantMode, ShopRandomizer
if TYPE_CHECKING:
from . import EarthBoundWorld
class LocationData(NamedTuple):
region: str
name: str
code: Optional[int]
def get_locations(world: "EarthBoundWorld") -> List[LocationData]:
location_table: List[LocationData] = [
LocationData("Northern Onett", "Onett - Tracy Gift", 0xEB0000),
LocationData("Northern Onett", "Onett - Tracy's Room Present", 0xEB0001),
LocationData("Northern Onett", "Onett - Hilltop Present", 0xEB0002),
LocationData("Northern Onett", "Onett - Meteor Item", 0xEB0003),
LocationData("Northern Onett", "Onett - Buzz Buzz", 0xEB0004),
LocationData("Northern Onett", "Onett - Mani Mani Statue", 0xEB0005),
LocationData("Onett", "Onett - Library Counter", 0xEB0006),
LocationData("Onett", "Onett - Library Bookshelf", 0xEB0007),
LocationData("Onett", "Onett - Burger Shop Trashcan", 0xEB0008),
LocationData("Onett", "Onett - Treehouse Guy", 0xEB0009),
LocationData("Onett", "Onett - South Road Present", 0xEB000A),
LocationData("Onett", "Onett - Hotel Trashcan", 0xEB000B),
LocationData("Onett", "Onett - Arcade Trashcan", 0xEB000C),
LocationData("Onett", "Onett - Mayor Pirkle", 0xEB000D),
LocationData("Onett", "Onett - Traveling Entertainer", 0xEB000E),
LocationData("Giant Step", "Giant Step - First Cave Present", 0xEB000F),
LocationData("Giant Step", "Giant Step - Floor 2 Cave Present", 0xEB0010),
LocationData("Giant Step", "Giant Step - Floor 3 Present", 0xEB0011),
LocationData("Twoson", "Twoson - Bike Shop Rental", 0xEB0012),
LocationData("Twoson", "Twoson - Antique Shop", 0xEB0013),
LocationData("Twoson", "Twoson - Paula's Room Present", 0xEB0014),
LocationData("Twoson", "Twoson - Apple Kid Trashcan", 0xEB0015),
LocationData("Twoson", "Twoson - South of Town Present", 0xEB0016),
LocationData("Twoson", "Twoson - Orange Kid Donation", 0xEB0017),
LocationData("Twoson", "Twoson - Apple Kid Invention", 0xEB0018),
LocationData("Twoson", "Twoson - Apple Kid's Mouse", 0xEB0019),
LocationData("Twoson", "Twoson - Paula's Mother", 0xEB001A),
LocationData("Everdred's House", "Twoson - Everdred Meeting", 0xEB001B),
LocationData("Twoson", "Twoson - Insignificant Location", 0xEB001C),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - Split Hill Present", 0xEB001D),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - Hill Nook Present", 0xEB001E),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - South of Bridge Present", 0xEB001F),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - Dead End Present", 0xEB0020),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - River Overlook Present", 0xEB0021),
LocationData("Peaceful Rest Valley", "Peaceful Rest Valley - North Side Present", 0xEB0022),
LocationData("Happy-Happy Village", "Happy-Happy Village - Donation Lady", 0xEB0023),
LocationData("Happy-Happy HQ", "Happy-Happy Village - Right HQ Present", 0xEB0024),
LocationData("Happy-Happy HQ", "Happy-Happy Village - Left HQ Present", 0xEB0025),
LocationData("Happy-Happy Village", "Happy-Happy Village - Prisoner Item", 0xEB0026),
LocationData("Happy-Happy Village", "Happy-Happy Village - Prisoner", 0xEB0027),
LocationData("Happy-Happy HQ", "Happy-Happy Village - Defeat Carpainter", 0xEB0028),
LocationData("Lilliput Steps", "Lilliput Steps - Southwest Pool Present", 0xEB0029),
LocationData("Lilliput Steps", "Lilliput Steps - East Cliff Present", 0xEB002A),
LocationData("Lilliput Steps", "Lilliput Steps - North Stream Present", 0xEB002B),
LocationData("Boogey Tent", "Threed - Boogey Tent Trashcan", 0xEB002C),
LocationData("Threed", "Threed - Cemetery Trashcan", 0xEB002D),
LocationData("Threed", "Threed - Downtown Trashcan", 0xEB002E),
LocationData("Threed", "Threed - East Side Trashcan", 0xEB002F),
LocationData("Threed", "Threed - Northeast Shack Trashcan", 0xEB0030),
LocationData("Threed", "Threed - Hospital Drawer", 0xEB0031),
LocationData("Threed", "Threed - Zombie Prisoner", 0xEB0032),
LocationData("Threed Underground", "Threed Underground - Left Coffin", 0xEB0033),
LocationData("Threed Underground", "Threed Underground - Right Coffin", 0xEB0034),
LocationData("Grapefruit Falls", "Grapefruit Falls - South Present", 0xEB0035),
LocationData("Grapefruit Falls", "Grapefruit Falls - North Present", 0xEB0036),
LocationData("Grapefruit Falls", "Grapefruit Falls - Saturn Cave Present", 0xEB0037),
LocationData("Saturn Valley", "Saturn Valley - Ladder Present", 0xEB0038),
LocationData("Saturn Valley", "Saturn Valley - Trashcan #1", 0xEB0039),
LocationData("Saturn Valley", "Saturn Valley - Trashcan #2", 0xEB003A),
LocationData("Saturn Valley", "Saturn Valley - Trashcan #3", 0xEB003B),
LocationData("Upper Saturn Valley", "Saturn Valley - Saturn Coffee", 0xEB003C),
LocationData("Saturn Valley", "Saturn Valley - Post Belch Gift #1", 0xEB003D),
LocationData("Saturn Valley", "Saturn Valley - Post Belch Gift #2", 0xEB003E),
LocationData("Saturn Valley", "Saturn Valley - Post Belch Gift #3", 0xEB003F),
LocationData("Milky Well", "Milky Well - Cavern Present", 0xEB0040),
LocationData("Belch's Factory", "Belch's Factory - Top Right Room Trashcan", 0xEB0041),
LocationData("Belch's Factory", "Belch's Factory - Pit Room Trashcan #1", 0xEB0042),
LocationData("Belch's Factory", "Belch's Factory - Pit Room Trashcan #2", 0xEB0043),
LocationData("Belch's Factory", "Belch's Factory - Balcony Room Trashcan #1", 0xEB0044),
LocationData("Belch's Factory", "Belch's Factory - Balcony Room Trashcan #2", 0xEB0045),
LocationData("Belch's Factory", "Belch's Factory - Balcony Room Trashcan #3", 0xEB0046),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Northwest Corner Present", 0xEB0047),
LocationData("Dusty Dunes Desert", "Dusty Dunes - South Side Present", 0xEB0048),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Surrounding Rocks Present", 0xEB0049),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Black Sesame Present", 0xEB004A),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Oasis Present", 0xEB004B),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Northeast Corner Present", 0xEB004C),
LocationData("Dusty Dunes Desert", "Dusty Dunes - North Central Present", 0xEB004D),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Shining Spot", 0xEB004E),
LocationData("Dusty Dunes Desert", "Dusty Dunes - East Peninsula Present", 0xEB004F),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Reward", 0xEB0050),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #1", 0xEB0051),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #2", 0xEB0052),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #3", 0xEB0053),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #4", 0xEB0054),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #5", 0xEB0055),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #6", 0xEB0056),
LocationData("Snow Wood Boarding School", "Snow Wood - Many Present Room Present #7", 0xEB0057),
LocationData("Scaraba", "Scaraba - Snake Bag Salesman", 0xEB0058),
LocationData("Snow Wood Boarding School", "Snow Wood - Upper Right Locker", 0xEB0059),
LocationData("Snow Wood Boarding School", "Snow Wood - Upper Left Locker", 0xEB005A),
LocationData("Snow Wood Boarding School", "Snow Wood - Bottom Right Locker", 0xEB005B),
LocationData("Snow Wood Boarding School", "Snow Wood - Bottom Left Locker", 0xEB005C),
LocationData("Snow Wood Boarding School", "Snow Wood - Maxwell Item", 0xEB005D),
LocationData("Snow Wood Boarding School", "Snow Wood - Bedroom", 0xEB005E),
LocationData("Winters", "Winters - Drugstore Saleswoman", 0xEB005F),
LocationData("Brickroad Maze", "Brick Road Maze - Top Path Present", 0xEB0060),
LocationData("Brickroad Maze", "Brick Road Maze - Guarded Present", 0xEB0061),
LocationData("Brickroad Maze", "Brick Road Maze - Out of the Way Present", 0xEB0062),
LocationData("Brickroad Maze", "Brick Road Maze - Alcove Present", 0xEB0063),
LocationData("Brickroad Maze", "Brick Road Maze - Near Exit Present", 0xEB0064),
LocationData("Rainy Circle", "Rainy Circle - Isolated Present", 0xEB0065),
LocationData("Rainy Circle", "Rainy Circle - East Cliff Present", 0xEB0066),
LocationData("Rainy Circle", "Rainy Circle - Near Ropes Present", 0xEB0067),
LocationData("Andonuts Lab Area", "Andonuts Lab - Present", 0xEB0068),
LocationData("Andonuts Lab Area", "Andonuts Lab - Mouse", 0xEB0069),
LocationData("Stonehenge Base", "Stonehenge - Purple Maze Present", 0xEB006A),
LocationData("Stonehenge Base", "Stonehenge - Dead End Present", 0xEB006B),
LocationData("Stonehenge Base", "Stonehenge - Near End of the Maze Present", 0xEB006C),
LocationData("Stonehenge Base", "Stonehenge - Bridge Room East Balcony Present", 0xEB006D),
LocationData("Stonehenge Base", "Stonehenge - Bridge Room Lower Present", 0xEB006E),
LocationData("Stonehenge Base", "Stonehenge - Flashing Room Right Path Present", 0xEB006F),
LocationData("Stonehenge Base", "Stonehenge - Flashing Room Center Present", 0xEB0070),
LocationData("Stonehenge Base", "Stonehenge - Flashing Room Upper Present", 0xEB0071),
LocationData("Stonehenge Base", "Stonehenge - Kidnapped Mr. Saturn", 0xEB0072),
LocationData("Stonehenge Base", "Stonehenge - Tony Item", 0xEB0073),
LocationData("Gold Mine", "Gold Mine - Mouse Crossroad Present #1", 0xEB0074),
LocationData("Gold Mine", "Gold Mine - Mouse Crossroad Present #2", 0xEB0075),
LocationData("Gold Mine", "Gold Mine - B1F Lonely Mole Present", 0xEB0076),
LocationData("Gold Mine", "Gold Mine - South Hall Present", 0xEB0077),
LocationData("Gold Mine", "Gold Mine - South Corner Present", 0xEB0078),
LocationData("Gold Mine", "Gold Mine - South Mole Present #1", 0xEB0079),
LocationData("Gold Mine", "Gold Mine - South Mole Present #2", 0xEB007A),
LocationData("Gold Mine", "Gold Mine - North Crossroad Detour Present", 0xEB007B),
LocationData("Gold Mine", "Gold Mine - North Mole Present", 0xEB007C),
LocationData("Gold Mine", "Gold Mine - West Mole Present", 0xEB007D),
LocationData("Gold Mine", "Gold Mine - B1F Isolated Present", 0xEB007E),
LocationData("Gold Mine", "Gold Mine - West Crossroad Detour Present", 0xEB007F),
LocationData("Gold Mine", "Gold Mine - B1F Junction Present", 0xEB0080),
LocationData("Gold Mine", "Gold Mine - B1F Junction Mole Present", 0xEB0081),
LocationData("Monkey Caves", "Monkey Caves - 1F Right Chest", 0xEB00F1),
LocationData("Monkey Caves", "Monkey Caves - 1F Left Chest", 0xEB00F2),
LocationData("Monkey Caves", "Monkey Caves - West 2F Left Chest", 0xEB00F3),
LocationData("Monkey Caves", "Monkey Caves - West 2F Right Chest #1", 0xEB00F4),
LocationData("Monkey Caves", "Monkey Caves - West 2F Right Chest #2", 0xEB00F5),
LocationData("Monkey Caves", "Monkey Caves - East 2F Left Chest", 0xEB00F6),
LocationData("Monkey Caves", "Monkey Caves - East 2F Right Chest", 0xEB00F7),
LocationData("Monkey Caves", "Monkey Caves - East West 3F Right Chest #1", 0xEB00F8),
LocationData("Monkey Caves", "Monkey Caves - East West 3F Right Chest #2", 0xEB00F9),
LocationData("Monkey Caves", "Monkey Caves - West End Chest", 0xEB0082),
LocationData("Monkey Caves", "Monkey Caves - West End Trashcan", 0xEB0083),
LocationData("Monkey Caves", "Monkey Caves - East End Chest", 0xEB0084),
LocationData("Monkey Caves", "Monkey Caves - East End Trashcan", 0xEB0085),
LocationData("Monkey Caves", "Monkey Caves - Bow Monkey Gift", 0xEB0086),
LocationData("Monkey Caves", "Monkey Caves - Talah Rama Chest #1", 0xEB0087),
LocationData("Monkey Caves", "Monkey Caves - Talah Rama Chest #2", 0xEB0088),
LocationData("Monkey Caves", "Monkey Caves - Talah Rama Gift", 0xEB0089),
LocationData("Monkey Caves", "Monkey Caves - Monkey Power", 0xEB008A),
LocationData("Fourside", "Fourside - Venus Gift", 0xEB008B),
LocationData("Moonside", "Fourside - Post-Moonside Delivery", 0xEB008C),
LocationData("Fourside", "Fourside - Bakery 2F Gift", 0xEB008D),
LocationData("Moonside", "Moonside - Two Trees Present", 0xEB008E),
LocationData("Moonside", "Moonside - East Island Present", 0xEB008F),
LocationData("Moonside", "Moonside - Businessman Present", 0xEB0090),
LocationData("Moonside", "Moonside - West Island Present", 0xEB0091),
LocationData("Moonside", "Moonside - Hospital Present", 0xEB0092),
LocationData("Fourside Dept. Store", "Fourside - Department Store Blackout", 0xEB0093),
LocationData("Magnet Hill", "Magnet Hill - West Entrance Trashcan", 0xEB0094),
LocationData("Magnet Hill", "Magnet Hill - First Room Free Door Trashcan", 0xEB0095),
LocationData("Magnet Hill", "Magnet Hill - First Room Barrel Door Trashcan", 0xEB0096),
LocationData("Magnet Hill", "Magnet Hill - Second Room Dead End Trashcan", 0xEB0097),
LocationData("Magnet Hill", "Magnet Hill - Final Room Door Trashcan", 0xEB0098),
LocationData("Magnet Hill", "Fourside - Magnet Hill Chest", 0xEB0099),
LocationData("Monotoli Building", "Monotoli Building - One Table Present", 0xEB009A),
LocationData("Monotoli Building", "Monotoli Building - Two Table Present", 0xEB009B),
LocationData("Monotoli Building", "Monotoli Building - Electra Gift", 0xEB009C),
LocationData("Monotoli Building", "Monotoli Building - Monotoli Gift", 0xEB009D),
LocationData("Monotoli Building", "Monotoli Building - Monotoli Character", 0xEB009E),
LocationData("Summers Museum", "Summers - Museum Item", 0xEB009F),
LocationData("Summers", "Summers - Magic Cake", 0xEB00A0),
LocationData("Dalaam", "Dalaam - Throne Room Chest #1", 0xEB00A1),
LocationData("Dalaam", "Dalaam - Throne Room Chest #2", 0xEB00A2),
LocationData("Dalaam", "Dalaam - Throne Room Chest #3", 0xEB00A3),
LocationData("Dalaam", "Dalaam - Trial of Mu", 0xEB00A4),
LocationData("Dalaam", "Dalaam - Restaurant Chest #1", 0xEB00A5),
LocationData("Dalaam", "Dalaam - Restaurant Chest #2", 0xEB00A6),
LocationData("Dalaam", "Dalaam - Do Do Guy's House Chest", 0xEB00A7),
LocationData("Dalaam", "Dalaam - Upper House Chest", 0xEB00A8),
LocationData("Dalaam", "Dalaam - Throne Character", 0xEB00A9),
LocationData("Ness's Mind", "Poo - Starting Item", 0xEB00AA),
LocationData("Pink Cloud", "Pink Cloud - Three Holes Present", 0xEB00AB),
LocationData("Pink Cloud", "Pink Cloud - Left Hole Present", 0xEB00AC),
LocationData("Pink Cloud", "Pink Cloud - Ground Floor Present", 0xEB00AD),
LocationData("Pyramid", "Pyramid - Anteroom Sarcophagus", 0xEB00AE),
LocationData("Pyramid", "Pyramid - Northwest Door Sarcophagus", 0xEB00AF),
LocationData("Pyramid", "Pyramid - Hallway Sarcophagus #1", 0xEB00B0),
LocationData("Pyramid", "Pyramid - Hallway Sarcophagus #2", 0xEB00B1),
LocationData("Pyramid", "Pyramid - Switch Room Sarcophagus", 0xEB00B2),
LocationData("Pyramid", "Pyramid - Pedestal Item", 0xEB00B3),
LocationData("Pyramid", "Pyramid - Way Out Sarcophagus", 0xEB00B4),
LocationData("Southern Scaraba", "Scaraba - Star Master", 0xEB00B5),
LocationData("Southern Scaraba", "Scaraba - Key Holder", 0xEB00B6),
LocationData("Dungeon Man", "Dungeon Man - 1F Dead End Present", 0xEB00B7),
LocationData("Dungeon Man", "Dungeon Man - 1F Long Walk Present", 0xEB00B8),
LocationData("Dungeon Man", "Dungeon Man - 1F Disappointing Present", 0xEB00B9),
LocationData("Dungeon Man", "Dungeon Man - 1F Opinion Present", 0xEB00BA),
LocationData("Dungeon Man", "Dungeon Man - 1F No Sign Present", 0xEB00BB),
LocationData("Dungeon Man", "Dungeon Man - 2F Unnecessary Billboard Present", 0xEB00BC),
LocationData("Dungeon Man", "Dungeon Man - 2F Dungeon Exploration Present", 0xEB00BD),
LocationData("Dungeon Man", "Dungeon Man - 2F South Ledge Present", 0xEB00BE),
LocationData("Dungeon Man", "Dungeon Man - 2F North Alcove Present", 0xEB00BF),
LocationData("Dungeon Man", "Dungeon Man - 3F Present", 0xEB00C0),
LocationData("Dungeon Man", "Dungeon Man - 2F Hole Present", 0xEB00C1),
LocationData("Dungeon Man", "Dungeon Man - 1F Exit Ledge Present", 0xEB00C2),
LocationData("Deep Darkness", "Deep Darkness - Teleporting Monkey", 0xEB00C3),
LocationData("Deep Darkness", "Deep Darkness - Crest of Darkness Present", 0xEB00C4),
LocationData("Deep Darkness Darkness", "Deep Darkness - Helicopter Present", 0xEB00C5),
LocationData("Deep Darkness Darkness", "Deep Darkness - Yellow Bird Present", 0xEB00C6),
LocationData("Deep Darkness Darkness", "Deep Darkness - Swamp Present", 0xEB00C7),
LocationData("Deep Darkness Darkness", "Deep Darkness - Corner Present", 0xEB00C8),
LocationData("Deep Darkness Darkness", "Deep Darkness - Alcove Present", 0xEB00C9),
LocationData("Deep Darkness Darkness", "Deep Darkness - North Alcove Truffle", 0xEB00CA),
LocationData("Deep Darkness Darkness", "Deep Darkness - Near Land Truffle", 0xEB00CB),
LocationData("Deep Darkness Darkness", "Deep Darkness - Present Truffle", 0xEB00CC),
LocationData("Deep Darkness Darkness", "Deep Darkness - Village Truffle", 0xEB00CD),
LocationData("Deep Darkness Darkness", "Deep Darkness - Entrance Truffle", 0xEB00CE),
LocationData("Deep Darkness Darkness", "Deep Darkness - Barf Character", 0xEB00CF),
LocationData("Tenda Village", "Tenda Village - Trashcan", 0xEB00D0),
LocationData("Tenda Village", "Tenda Village - Tenda Tea", 0xEB00D1),
LocationData("Tenda Village", "Tenda Village - Tenda Gift", 0xEB00D2),
LocationData("Tenda Village", "Tenda Village - Tenda Gift #2", 0xEB00D3),
LocationData("Lumine Hall", "Lumine Hall - B1F Non-Talkative Rock Present", 0xEB00D4),
LocationData("Lumine Hall", "Lumine Hall - 1F North Path Present", 0xEB00D5),
LocationData("Lumine Hall", "Lumine Hall - B1F Thankful Rock Corner Present", 0xEB00D6),
LocationData("Lumine Hall", "Lumine Hall - B1F Thankful Rock Junction Present", 0xEB00D7),
LocationData("Lumine Hall", "Lumine Hall - 1F Above Belly Button Present", 0xEB00D8),
LocationData("Lumine Hall", "Lumine Hall - B1F Belly Button Present", 0xEB00D9),
LocationData("Lumine Hall", "Lumine Hall - 1F Near Exit Present", 0xEB00DA),
LocationData("Lumine Hall", "Lumine Hall - 1F Dead End Present", 0xEB00DB),
LocationData("Lumine Hall", "Lumine Hall - B1F West Alcove Present", 0xEB00DC),
LocationData("Lost Underworld", "Lost Underworld - Talking Rock", 0xEB00DD),
LocationData("Lost Underworld", "Lost Underworld - East Present", 0xEB00DE),
LocationData("Lost Underworld", "Lost Underworld - Northeast Present", 0xEB00DF),
LocationData("Lost Underworld", "Lost Underworld - Northeast of Tenda Tribe Present", 0xEB00E0),
LocationData("Lost Underworld", "Lost Underworld - Southwest of Tenda Tribe Present", 0xEB00E1),
LocationData("Lost Underworld", "Lost Underworld - Evacuation Present", 0xEB00E2),
LocationData("Fire Spring", "Fire Spring - 1st Cave Present", 0xEB00E3),
LocationData("Fire Spring", "Fire Spring - East Corner Present", 0xEB00E4),
LocationData("Fire Spring", "Fire Spring - Volcano Present", 0xEB00E5),
LocationData("Fire Spring", "Fire Spring - Lone Cave Present", 0xEB00E6),
LocationData("Fire Spring", "Fire Spring - Upper Volcano Present", 0xEB00E7),
LocationData("Cave of the Present", "Cave of the Present - Star Master", 0xEB00EE),
LocationData("Cave of the Present", "Cave of the Present - Broken Phase Distorter", 0xEB00EF),
LocationData("Happy-Happy HQ", "Carpainter Defeated", None),
LocationData("Belch's Factory", "Belch Defeated", None),
LocationData("Dungeon Man", "Dungeon Man Submarine", None),
LocationData("Giant Step", "Giant Step Sanctuary", None),
LocationData("Lilliput Steps", "Lilliput Steps Sanctuary", None),
LocationData("Milky Well", "Milky Well Sanctuary", None),
LocationData("Rainy Circle", "Rainy Circle Sanctuary", None),
LocationData("Magnet Hill", "Magnet Hill Sanctuary", None),
LocationData("Pink Cloud", "Pink Cloud Sanctuary", None),
LocationData("Lumine Hall", "Lumine Hall Sanctuary", None),
LocationData("Fire Spring", "Fire Spring Sanctuary", None),
LocationData("Ness's Mind", "Sanctuary Goal", None),
LocationData("Global ATM Access", "Any ATM", None)
]
if world.options.giygas_required:
location_table += [
LocationData("Cave of the Past", "Cave of the Past - Present", 0xEB00F0),
LocationData("Endgame", "Giygas", None),
]
if world.options.alternate_sanctuary_goal:
location_table += [
LocationData("Ness's Mind", "+2 Sanctuaries", None)
]
if world.options.magicant_mode in range(1, 3):
location_table += [
LocationData("Sea of Eden", "Magicant - Ness's Nightmare", None),
]
if not world.options.magicant_mode:
location_table += [
LocationData("Sea of Eden", "Magicant - Ness's Nightmare", 0xEB00ED),
]
if world.options.magicant_mode < MagicantMode.option_alternate_goal:
location_table += [
LocationData("Magicant", "Magicant - Ness's Gift", 0xEB00E8),
LocationData("Magicant", "Magicant - Present Near Ness", 0xEB00E9),
LocationData("Magicant", "Magicant - Lonely Present", 0xEB00EA),
LocationData("Magicant", "Magicant - North Present", 0xEB00EB),
LocationData("Magicant", "Magicant - Hills Present", 0xEB00EC),
LocationData("Magicant", "Magicant - Town Present", 0xEB00FA)
]
if world.options.magicant_mode == MagicantMode.option_alternate_goal:
location_table += [
LocationData("Ness's Mind", "+1 Sanctuary", None)
]
if world.options.shop_randomizer == ShopRandomizer.option_shopsanity:
location_table += [
LocationData("Onett", "Onett Drugstore - Right Counter Slot 1", 0xeb1000),
LocationData("Onett", "Onett Drugstore - Right Counter Slot 2", 0xeb1001),
LocationData("Onett", "Onett Drugstore - Right Counter Slot 3", 0xeb1002),
LocationData("Onett", "Onett Drugstore - Right Counter Slot 4", 0xeb1003),
LocationData("Onett", "Onett Drugstore - Right Counter Slot 5", 0xeb1004),
LocationData("Onett", "Onett Drugstore - Left Counter", 0xeb1007),
LocationData("Summers", "Summers - Beach Cart", 0xeb100e),
LocationData("Onett", "Onett Burger Shop - Slot 1", 0xeb1015),
LocationData("Onett", "Onett Burger Shop - Slot 2", 0xeb1016),
LocationData("Onett", "Onett Burger Shop - Slot 3", 0xeb1017),
LocationData("Onett", "Onett Burger Shop - Slot 4", 0xeb1018),
LocationData("Onett", "Onett Bakery - Slot 1", 0xeb101c),
LocationData("Onett", "Onett Bakery - Slot 2", 0xeb101d),
LocationData("Onett", "Onett Bakery - Slot 3", 0xeb101e),
LocationData("Onett", "Onett Bakery - Slot 4", 0xeb101f),
LocationData("Twoson", "Twoson Department Store Burger Shop - Slot 1", 0xeb1023),
LocationData("Twoson", "Twoson Department Store Burger Shop - Slot 2", 0xeb1024),
LocationData("Twoson", "Twoson Department Store Burger Shop - Slot 3", 0xeb1025),
LocationData("Twoson", "Twoson Department Store Burger Shop - Slot 4", 0xeb1026),
LocationData("Twoson", "Twoson Department Store Bakery - Slot 1", 0xeb102a),
LocationData("Twoson", "Twoson Department Store Bakery - Slot 2", 0xeb102b),
LocationData("Twoson", "Twoson Department Store Bakery - Slot 3", 0xeb102c),
LocationData("Twoson", "Twoson Department Store Bakery - Slot 4", 0xeb102d),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 1", 0xeb1031),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 2", 0xeb1032),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 3", 0xeb1033),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 4", 0xeb1034),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 5", 0xeb1035),
LocationData("Twoson", "Twoson Department Store Top Floor - Right Counter Slot 6", 0xeb1036),
LocationData("Twoson", "Twoson Department Store Top Floor - Left Counter Slot 1", 0xeb1038),
LocationData("Twoson", "Twoson Department Store Top Floor - Left Counter Slot 2", 0xeb1039),
LocationData("Summers", "Summers - Magic Cake Cart Shop Slot", 0xeb103f),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 1", 0xeb1046),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 2", 0xeb1047),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 3", 0xeb1048),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 4", 0xeb1049),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 5", 0xeb104a),
LocationData("Twoson", "Burglin Park Junk Shop - Slot 6", 0xeb104b),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 1", 0xeb105b),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 2", 0xeb105c),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 3", 0xeb105d),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 4", 0xeb105e),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 5", 0xeb105f),
LocationData("Twoson", "Burglin Park Bread Stand - Slot 6", 0xeb1060),
LocationData("Twoson", "Burglin Park - Banana Stand", 0xeb1062),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Right Counter Slot 1", 0xeb1069),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Right Counter Slot 2", 0xeb106a),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Right Counter Slot 3", 0xeb106b),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Right Counter Slot 4", 0xeb106c),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Right Counter Slot 5", 0xeb106d),
LocationData("Threed", "Threed Drugstore - Right Counter Slot 1", 0xeb1070),
LocationData("Threed", "Threed Drugstore - Right Counter Slot 2", 0xeb1071),
LocationData("Threed", "Threed Drugstore - Right Counter Slot 3", 0xeb1072),
LocationData("Threed", "Threed Drugstore - Right Counter Slot 4", 0xeb1073),
LocationData("Threed", "Threed Drugstore - Right Counter Slot 5", 0xeb1074),
LocationData("Threed", "Threed Drugstore - Left Counter Slot 1", 0xeb1077),
LocationData("Threed", "Threed Drugstore - Left Counter Slot 2", 0xeb1078),
LocationData("Threed", "Threed Drugstore - Left Counter Slot 3", 0xeb1079),
LocationData("Threed", "Threed Drugstore - Left Counter Slot 4", 0xeb107a),
LocationData("Threed", "Threed Drugstore - Left Counter Slot 5", 0xeb107b),
LocationData("Threed", "Threed - Arms Dealer Slot 1", 0xeb107e),
LocationData("Threed", "Threed - Arms Dealer Slot 2", 0xeb107f),
LocationData("Threed", "Threed - Arms Dealer Slot 3", 0xeb1080),
LocationData("Threed", "Threed - Arms Dealer Slot 4", 0xeb1081),
LocationData("Threed", "Threed Bakery - Slot 1", 0xeb1085),
LocationData("Threed", "Threed Bakery - Slot 2", 0xeb1086),
LocationData("Threed", "Threed Bakery - Slot 3", 0xeb1087),
LocationData("Threed", "Threed Bakery - Slot 4", 0xeb1088),
LocationData("Threed", "Threed Bakery - Slot 5", 0xeb1089),
LocationData("Threed", "Threed Bakery - Slot 6", 0xeb108a),
LocationData("Threed", "Threed Bakery - Slot 7", 0xeb108b),
LocationData("Scaraba", "Scaraba - Expensive Water Guy", 0xeb108c),
LocationData("Winters", "Winters Drugstore - Slot 1", 0xeb1093),
LocationData("Winters", "Winters Drugstore - Slot 2", 0xeb1094),
LocationData("Winters", "Winters Drugstore - Slot 3", 0xeb1095),
LocationData("Winters", "Winters Drugstore - Slot 4", 0xeb1096),
LocationData("Winters", "Winters Drugstore - Slot 5", 0xeb1097),
LocationData("Winters", "Winters Drugstore - Slot 6", 0xeb1098),
LocationData("Winters", "Winters Drugstore - Slot 7", 0xeb1099),
LocationData("Saturn Valley", "Saturn Valley Shop - Center Saturn Slot 1", 0xeb109a),
LocationData("Saturn Valley", "Saturn Valley Shop - Center Saturn Slot 2", 0xeb109b),
LocationData("Saturn Valley", "Saturn Valley Shop - Center Saturn Slot 3", 0xeb109c),
LocationData("Saturn Valley", "Saturn Valley Shop - Center Saturn Slot 4", 0xeb109d),
LocationData("Saturn Valley", "Saturn Valley Shop - Center Saturn Slot 5", 0xeb109e),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Counter Slot 1", 0xeb10a1),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Counter Slot 2", 0xeb10a2),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Counter Slot 3", 0xeb10a3),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Counter Slot 4", 0xeb10a4),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Counter Slot 5", 0xeb10a5),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Arms Dealer Slot 1", 0xeb10a8),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Arms Dealer Slot 2", 0xeb10a9),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Arms Dealer Slot 3", 0xeb10aa),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Arms Dealer Slot 4", 0xeb10ab),
LocationData("Fourside", "Fourside Bakery - Slot 1", 0xeb10af),
LocationData("Fourside", "Fourside Bakery - Slot 2", 0xeb10b0),
LocationData("Fourside", "Fourside Bakery - Slot 3", 0xeb10b1),
LocationData("Fourside", "Fourside Bakery - Slot 4", 0xeb10b2),
LocationData("Fourside", "Fourside Bakery - Slot 5", 0xeb10b3),
LocationData("Fourside", "Fourside Bakery - Slot 6", 0xeb10b4),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 1", 0xeb10b6),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 2", 0xeb10b7),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 3", 0xeb10b8),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 4", 0xeb10b9),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 5", 0xeb10ba),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 6", 0xeb10bb),
LocationData("Fourside", "Fourside Department Store - Tool Shop Slot 7", 0xeb10bc),
LocationData("Fourside", "Fourside Department Store - Shop Shop Slot 1", 0xeb10bd),
LocationData("Fourside", "Fourside Department Store - Shop Shop Slot 2", 0xeb10be),
LocationData("Fourside", "Fourside Department Store - Shop Shop Slot 3", 0xeb10bf),
LocationData("Fourside", "Fourside Department Store - Shop Shop Slot 4", 0xeb10c0),
LocationData("Fourside", "Fourside Department Store - Food Shop Slot 1", 0xeb10c4),
LocationData("Fourside", "Fourside Department Store - Food Shop Slot 2", 0xeb10c5),
LocationData("Fourside", "Fourside Department Store - Food Shop Slot 3", 0xeb10c6),
LocationData("Fourside", "Fourside Department Store - Food Shop Slot 4", 0xeb10c7),
LocationData("Fourside", "Fourside Department Store - Food Shop Slot 5", 0xeb10c8),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 1", 0xeb10cb),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 2", 0xeb10cc),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 3", 0xeb10cd),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 4", 0xeb10ce),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 5", 0xeb10cf),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 6", 0xeb10d0),
LocationData("Fourside", "Fourside Department Store - 2F Cart Slot 7", 0xeb10d1),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 1", 0xeb10d2),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 2", 0xeb10d3),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 3", 0xeb10d4),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 4", 0xeb10d5),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 5", 0xeb10d6),
LocationData("Fourside", "Fourside Department Store - Toys Shop Slot 6", 0xeb10d7),
LocationData("Fourside", "Fourside Department Store - Sports Shop Slot 1", 0xeb10d9),
LocationData("Fourside", "Fourside Department Store - Sports Shop Slot 2", 0xeb10da),
LocationData("Fourside", "Fourside Department Store - Sports Shop Slot 3", 0xeb10db),
LocationData("Fourside", "Fourside Department Store - Sports Shop Slot 4", 0xeb10dc),
LocationData("Fourside", "Fourside Department Store - Burger Shop Slot 1", 0xeb10e0),
LocationData("Fourside", "Fourside Department Store - Burger Shop Slot 2", 0xeb10e1),
LocationData("Fourside", "Fourside Department Store - Burger Shop Slot 3", 0xeb10e2),
LocationData("Fourside", "Fourside Department Store - Burger Shop Slot 4", 0xeb10e3),
LocationData("Fourside", "Fourside Department Store - Burger Shop Slot 5", 0xeb10e4),
LocationData("Fourside", "Fourside Department Store - Arms Dealer Slot 1", 0xeb10e7),
LocationData("Fourside", "Fourside Department Store - Arms Dealer Slot 2", 0xeb10e8),
LocationData("Fourside", "Fourside Department Store - Arms Dealer Slot 3", 0xeb10e9),
LocationData("Fourside", "Fourside Department Store - Arms Dealer Slot 4", 0xeb10ea),
LocationData("Fourside", "Fourside Department Store - Arms Dealer Slot 5", 0xeb10eb),
LocationData("Fourside", "Fourside - Northeast Alley Junk Shop Slot 1", 0xeb10ee),
LocationData("Fourside", "Fourside - Northeast Alley Junk Shop Slot 2", 0xeb10ef),
LocationData("Fourside", "Fourside - Northeast Alley Junk Shop Slot 3", 0xeb10f0),
LocationData("Fourside", "Fourside - Northeast Alley Junk Shop Slot 4", 0xeb10f1),
LocationData("Summers", "Summers - Scam Shop Slot 1", 0xeb1103),
LocationData("Summers", "Summers - Scam Shop Slot 2", 0xeb1104),
LocationData("Summers", "Summers - Scam Shop Slot 3", 0xeb1105),
LocationData("Summers", "Summers - Scam Shop Slot 4", 0xeb1106),
LocationData("Summers", "Summers - Scam Shop Slot 5", 0xeb1107),
LocationData("Summers", "Summers - Scam Shop Slot 6", 0xeb1108),
LocationData("Summers", "Summers - Scam Shop Slot 7", 0xeb1109),
LocationData("Summers", "Summers Harbor - Shop Slot 1", 0xeb110a),
LocationData("Summers", "Summers Harbor - Shop Slot 2", 0xeb110b),
LocationData("Summers", "Summers Harbor - Shop Slot 3", 0xeb110c),
LocationData("Summers", "Summers Harbor - Shop Slot 4", 0xeb110d),
LocationData("Summers", "Summers Harbor - Shop Slot 5", 0xeb110e),
LocationData("Summers", "Summers Harbor - Shop Slot 6", 0xeb110f),
LocationData("Summers", "Summers Harbor - Shop Slot 7", 0xeb1110),
LocationData("Summers", "Summers Restaurant - Slot 1", 0xeb1111),
LocationData("Summers", "Summers Restaurant - Slot 2", 0xeb1112),
LocationData("Summers", "Summers Restaurant - Slot 3", 0xeb1113),
LocationData("Summers", "Summers Restaurant - Slot 4", 0xeb1114),
LocationData("Summers", "Summers Restaurant - Slot 5", 0xeb1115),
LocationData("Summers", "Summers Restaurant - Slot 6", 0xeb1116),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 1", 0xeb1118),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 2", 0xeb1119),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 3", 0xeb111a),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 4", 0xeb111b),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 5", 0xeb111c),
LocationData("Scaraba", "Scaraba - Indoors Shop Slot 6", 0xeb111d),
LocationData("Scaraba", "Scaraba Bazaar - Red Snake Carpet Slot 1", 0xeb1126),
LocationData("Scaraba", "Scaraba Bazaar - Red Snake Carpet Slot 2", 0xeb1127),
LocationData("Scaraba", "Scaraba Bazaar - Red Snake Carpet Slot 3", 0xeb1128),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 1", 0xeb112d),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 2", 0xeb112e),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 3", 0xeb112f),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 4", 0xeb1130),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 5", 0xeb1131),
LocationData("Scaraba", "Scaraba Bazaar - Bottom Left Carpet Slot 6", 0xeb1132),
LocationData("Scaraba", "Scaraba Hotel - Arms Dealer Slot 1", 0xeb1134),
LocationData("Scaraba", "Scaraba Hotel - Arms Dealer Slot 2", 0xeb1135),
LocationData("Scaraba", "Scaraba Hotel - Arms Dealer Slot 3", 0xeb1136),
LocationData("Scaraba", "Scaraba Hotel - Arms Dealer Slot 4", 0xeb1137),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 1", 0xeb113b),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 2", 0xeb113c),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 3", 0xeb113d),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 4", 0xeb113e),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 5", 0xeb113f),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 6", 0xeb1140),
LocationData("Deep Darkness", "Deep Darkness - Businessman Slot 7", 0xeb1141),
LocationData("Saturn Valley", "Saturn Valley Shop - Post-Belch Saturn Slot 1", 0xeb1157),
LocationData("Saturn Valley", "Saturn Valley Shop - Post-Belch Saturn Slot 2", 0xeb1158),
LocationData("Saturn Valley", "Saturn Valley Shop - Post-Belch Saturn Slot 3", 0xeb1159),
LocationData("Saturn Valley", "Saturn Valley Shop - Post-Belch Saturn Slot 4", 0xeb115a),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 1", 0xeb115e),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 2", 0xeb115f),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 3", 0xeb1160),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 4", 0xeb1161),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 5", 0xeb1162),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 6", 0xeb1163),
LocationData("Southern Scaraba", "Scaraba - Southern Camel Shop Slot 7", 0xeb1164),
LocationData("Deep Darkness", "Deep Darkness - Arms Dealer Slot 1", 0xeb1165),
LocationData("Deep Darkness", "Deep Darkness - Arms Dealer Slot 2", 0xeb1166),
LocationData("Deep Darkness", "Deep Darkness - Arms Dealer Slot 3", 0xeb1167),
LocationData("Deep Darkness", "Deep Darkness - Arms Dealer Slot 4", 0xeb1168),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 1", 0xeb116c),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 2", 0xeb116d),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 3", 0xeb116e),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 4", 0xeb116f),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 5", 0xeb1170),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 6", 0xeb1171),
LocationData("Lost Underworld", "Lost Underworld - Tenda Camp Shop Slot 7", 0xeb1172),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 1", 0xeb117a),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 2", 0xeb117b),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 3", 0xeb117c),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 4", 0xeb117d),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 5", 0xeb117e),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 6", 0xeb117f),
LocationData("Happy-Happy Village", "Happy-Happy Village Drugstore - Left Counter Slot 7", 0xeb1180),
LocationData("Grapefruit Falls", "Grapefruit Falls - Hiker Shop Slot 1", 0xeb1181),
LocationData("Grapefruit Falls", "Grapefruit Falls - Hiker Shop Slot 2", 0xeb1182),
LocationData("Grapefruit Falls", "Grapefruit Falls - Hiker Shop Slot 3", 0xeb1183),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 1", 0xeb1188),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 2", 0xeb1189),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 3", 0xeb118a),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 4", 0xeb118b),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 5", 0xeb118c),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 6", 0xeb118d),
LocationData("Saturn Valley", "Saturn Valley Shop - Top Saturn Slot 7", 0xeb118e),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 1", 0xeb118f),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 2", 0xeb1190),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 3", 0xeb1191),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 4", 0xeb1192),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 5", 0xeb1193),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 6", 0xeb1194),
LocationData("Dusty Dunes Desert", "Dusty Dunes Drugstore - Left Shop Slot 7", 0xeb1195),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 1", 0xeb1196),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 2", 0xeb1197),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 3", 0xeb1198),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 4", 0xeb1199),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 5", 0xeb119a),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 6", 0xeb119b),
LocationData("Dusty Dunes Desert", "Dusty Dunes - Mine Food Cart Slot 7", 0xeb119c),
LocationData("Moonside", "Moonside Hotel - Shop Slot 1", 0xeb119d),
LocationData("Moonside", "Moonside Hotel - Shop Slot 2", 0xeb119e),
LocationData("Moonside", "Moonside Hotel - Shop Slot 3", 0xeb119f),
LocationData("Moonside", "Moonside Hotel - Shop Slot 4", 0xeb11a0),
LocationData("Moonside", "Moonside Hotel - Shop Slot 5", 0xeb11a1),
LocationData("Dalaam", "Dalaam Restaurant - Slot 1", 0xeb11a4),
LocationData("Dalaam", "Dalaam Restaurant - Slot 2", 0xeb11a5),
LocationData("Dalaam", "Dalaam Restaurant - Slot 3", 0xeb11a6),
LocationData("Dalaam", "Dalaam Restaurant - Slot 4", 0xeb11a7),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 1", 0xeb11ab),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 2", 0xeb11ac),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 3", 0xeb11ad),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 4", 0xeb11ae),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 5", 0xeb11af),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 6", 0xeb11b0),
LocationData("Scaraba", "Scaraba Bazaar - Delicacy Shop Slot 7", 0xeb11b1),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 1", 0xeb11b2),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 2", 0xeb11b3),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 3", 0xeb11b4),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 4", 0xeb11b5),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 5", 0xeb11b6),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 6", 0xeb11b7),
LocationData("Common Condiment Shop", "Twoson/Scaraba - Shared Condiment Shop Slot 7", 0xeb11b8),
LocationData("Andonuts Lab Area", "Andonuts Lab - Caveman Shop Slot 1", 0xeb11c0),
LocationData("Andonuts Lab Area", "Andonuts Lab - Caveman Shop Slot 2", 0xeb11c1),
LocationData("Andonuts Lab Area", "Andonuts Lab - Caveman Shop Slot 3", 0xeb11c2),
LocationData("Andonuts Lab Area", "Andonuts Lab - Caveman Shop Slot 4", 0xeb11c3),
LocationData("Andonuts Lab Area", "Andonuts Lab - Caveman Shop Slot 5", 0xeb11c4)
]
if world.options.magicant_mode < MagicantMode.option_alternate_goal:
location_table += [
LocationData("Magicant", "Magicant - Shop Slot 1", 0xeb10f5),
LocationData("Magicant", "Magicant - Shop Slot 2", 0xeb10f6)
]
return location_table

View File

@@ -0,0 +1,625 @@
from dataclasses import dataclass
from Options import (Toggle, DefaultOnToggle, DeathLink, Choice, Range, PerGameCommonOptions, StartInventoryPool,
OptionGroup, FreeText, Visibility, PlandoBosses)
from .modules.boss_shuffle import boss_plando_keys
class GiygasRequired(DefaultOnToggle):
"""If enabled, your goal will be to defeat Giygas at the Cave of the Past.
If disabled, your goal will either complete automatically upon completing
enough Sanctuaries, or completing Magicant if it is required."""
display_name = "Giygas Required"
class SanctuariesRequired(Range):
"""How many of the eight "Your Sanctuary" locations are required to be cleared."""
display_name = "Required Sanctuaries"
range_start = 1
range_end = 8
default = 4
class SanctuaryAltGoal(Toggle):
"""If enabled, you will be able to win by completing 2 more Sanctuaries than are required.
Does nothing if 7 or more Sanctuaries are required, or if Magicant and Giygas are not required."""
display_name = "Sanctuary Alternate Goal"
class MagicantMode(Choice):
"""PSI Location: You will be able to find a Magicant teleport item. Ness's Nightmare contains a PSI location, and no stat boost.
Required: You will unlock the Magicant Teleport upon reaching your Sanctuary goal. If Giygas is required, beating Ness's Nightmare will unlock the Cave of the Past and grant a party-wide stat boost. Otherwise, Ness's Nightmare will finish your game.
Alternate Goal: You will unlock the Magicant Teleport upon reaching one more Sanctuary than required. Beating Ness's Nightmare will finish your game. Does nothing if Giygas is not required, or if 8 Sanctuaries are required. Magicant locations are removed from the multiworld, but contain random junk for yourself.
Optional Boost: You will be able to find a Magicant teleport item. Beating Ness's Nightmare will grant a party-wide stat boost. Magicant locations are removed from the multiworld, but contain random junk for yourself.
Removed: Magicant will be completely inacessible."""
display_name = "Magicant Mode"
option_psi_location = 0
option_required = 1
option_alternate_goal = 2
option_optional_boost = 3
option_removed = 4
default = 0
class MonkeyCavesMode(Choice):
"""Chests: Items required to finish the Monkey Caves will be forcibly placed on the chests that can be found in-between rooms of the monkey caves. The "reward" locations, usually found at the end of a branch, are still random. If you waste chest items, they will need to be replaced via the methods in hunt mode.
Hunt: Items required to finish the Monkey Caves will needsell you every minor item needed to be found outside. They can be obtained from the Dusty Dunes drugstore, the Fourside department store, and the pizza shop in either Twoson or Threed.
Shop: The monkey outside the Monkey Caves will sell every item needed to complete the caves and is not affected by shop randomization.
Solved: The Monkey Caves monkeys will already be moved out of the way and not require any items."""
display_name = "Monkey Caves Mode"
option_chests = 0
option_hunt = 1
option_shop = 2
option_solved = 3
default = 1
class ShortenPrayers(DefaultOnToggle):
"""If enabled, the Prayer cutscenes while fighting Giygas will be skipped, excluding the final one."""
display_name = "Skip Prayer Sequences"
class RandomStartLocation(Toggle):
"""If disabled, you will always start at Ness's house with no teleports unlocked.
If enabled, you will start at a random teleport destination with one teleport unlocked.
Additionally, you will need to fight Captain Strong to access the north part of Onett if this is enabled."""
display_name = "Random Starting Location"
class LocalTeleports(Toggle):
"""Forces all teleports and Poo PSI to be placed locally in your world."""
display_name = "Local Teleports"
class CharacterShuffle(Choice):
"""Shuffled: Characters will be shuffled amongst Character Locations. Extra locations will have Flying Man, a Teddy Bear, or a Super Plush Bear.
Anywhere: Characters can be found anywhere in the multiworld, and character locations will have regular checks.
See the Game Page for more information on Character Locations."""
display_name = "Character Shuffle"
option_shuffled = 0
option_anywhere = 1
default = 0
class PSIShuffle(Choice):
"""None: Characters will learn their normal PSI skills.
Basic: Offensive and Assist PSI will be shuffled. Recovery PSI is not modified. Ness's Favorite Thing will be named Wave in other slots.
Extended: Basic shuffle, but includes Jeff gadgets and some combat items.
See the Game Page for more information."""
display_name = "PSI Shuffle"
option_none = 0
option_basic = 1
option_extended = 2
class BossShuffle(PlandoBosses):
"""Shuffles boss encounters amongst each other."""
display_name = "Boss Shuffle"
option_false = 0
option_true = 1
default = 0
bosses = boss_plando_keys
locations = boss_plando_keys
duplicate_bosses = False
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
return True
class DecoupleDiamondDog(Toggle):
"""Shuffles Diamond Dog as a boss separate from Carbon Dog. Carbon Dog will transform into a random boss.
Does nothing if Boss Shuffle is disabled."""
display_name = "Decouple Diamond Dog"
class ShuffleGiygas(Toggle):
"""Adds the standalone Giygas fight to the shuffled boss pool.
This only applies to the second phase Giygas. The prayer fight is not affected.
Does nothing if Boss Shuffle is disabled."""
display_name = "Add Giygas to Boss Pool"
class BanFlashFavorite(Toggle):
"""If enabled, allows PSI Flash to be shuffled onto the Favorite Thing PSI slot. Can be quite annoying early-game.
Does nothing if PSI Shuffle is set to None."""
display_name = "Flash as Favorite"
class PreFixItems(Toggle):
"""If enabled, broken items in the multiworld pool will be replaced with their fixed versions.
This does not affect any items that are not placed by the multiworld."""
display_name = "Prefixed Items"
class AutoscaleParty(Toggle):
"""If enabled, joining party members will be scaled to roughly the level of the sphere they were obtained in."""
display_name = "Autoscale Party Members"
class ProgressiveWeapons(Toggle):
"""If enabled, Bats, Fry Pans, and Guns will be progressive. Does not apply to items dropped by enemies or found in shops."""
display_name = "Progressive Weapons"
class ProgressiveArmor(Toggle):
"""If enabled, Bracelets and items for the Other slot besides Ribbons will be progressive. Does not apply to items dropped by enemies or found in shops."""
display_name = "Progressive Armor"
class PresentSprites(DefaultOnToggle):
"""If enabled, Presents, Trash cans, and chests will have their appearance modified to be indicative of the item they contain."""
display_name = "Match Present Sprites"
class NoAPPresents(Toggle):
"""If enabled, present that contain items for other players will appear as EarthBound presents (trashcan, present, and chest) instead of Archipelago boxes.
Does nothing if Presents Match Contents is disabled."""
class ShuffleDrops(Toggle):
"""If enabled, enemies will drop random filler items. This does not put checks on enemy drops.
Drop rates are unchanged."""
display_name = "Shuffle Drops"
class RandomFranklinBadge(Toggle):
"""If enabled, the Franklin Badge will reflect a randomly selected attack type. The type can be determined from the item's name, as well as the help
text for it. The badge's function outside of battle will not change, and neither will its name outside of the game itself."""
display_name = "Franklin Badge Protection"
class CommonWeight(Range):
"""Weight for placing a common filler item."""
display_name = "Common Filler Weight"
range_start = 1
range_end = 100
default = 80
class UncommonWeight(Range):
"""Weight for placing an uncommon filler item."""
display_name = "Uncommon Filler Weight"
range_start = 1
range_end = 100
default = 30
class RareWeight(Range):
"""Weight for placing a rare filler item."""
display_name = "Rare Filler Weight"
range_start = 0
range_end = 100
default = 5
class MoneyWeight(Range):
"""Weight for placing money in the item pool."""
display_name = "Money Weight"
range_start = 0
range_end = 100
default = 0
class ExperienceModifier(Range):
"""Percentage of EXP enemies give you. 100 is vanilla, after scaling, and 300 is x3."""
display_name = "Experience Percentage"
range_start = 100
range_end = 300
default = 150
class StartingMoney(Range):
"""How much money you start with."""
display_name = "Starting Money"
range_start = 0
range_end = 99999
default = 20
class EasyDeaths(DefaultOnToggle):
"""Fully revives and heals all party members after death. If off, only Ness will be healed with 0 PP."""
display_name = "Easy Deaths"
class RandomFlavors(DefaultOnToggle):
"""Randomizes the non-plain window color options."""
display_name = "Random Flavors"
class DeathLinkMode(Choice):
"""Controls how receiving a Deathlink functions in battle.
Instant: The player will be instantly defeated.
Mortal: All characters will receieve mortal damage. The player will not be able to heal until the battle is finished.
Mortal Mercy: All characters will receieve mortal damage, but the player will be able to heal it before they die.
Regardless of this setting, receiving a deathlink outside of battle will always instantly defeat the player."""
display_name = "Death Link Mode"
option_instant = 0
option_mortal = 1
option_mortal_mercy = 2
default = 1
class RandomBattleBG(Toggle):
"""Generates random battle backgrounds."""
display_name = "Randomize Battle Backgrounds"
class RandomSwirlColors(Toggle):
"""Generates random colors for pre-battle swirls."""
display_name = "Randomize Swirl Colors"
class RemoteItems(Toggle):
"""If enabled, you will receive your own items from the server upon collecting them, rather than locally.
This allows co-op within the same game, and protects against loss of save data.
However, you will not be able to play offline if this is enabled."""
display_name = "Remote Items"
class PlandoLumineHallText(FreeText):
"""Set text to be displayed at Lumine Hall. If nothing is entered, random community-submitted text will be selected instead."""
display_name = "Lumine Hall Text Plando"
visibility = Visibility.none
class Armorizer(Choice):
"""All equippable armor will have randomly generated attributes. This includes who can equip it, elemental resistance (and how strong that resistance is),
defense, and the secondary stat it increases (Either Luck or Speed, depending on armor slot.) Choosing "Help!" from the Goods menu will give you exact details
on that piece of equipment.
Keep Type: Equipment will keep its original equipment slot. If Progressive Armor is enabled, you will get armor with progressively higher defense.
Chaos: Equipment will have a randomly selected slot. It will try to respect the defense progressively, but the type may not match the type received."""
display_name = "Armorizer"
option_off = 0
option_keep_type = 1
option_chaos = 2
default = 0
class Weaponizer(Choice):
"""All weapons will have randomly generated attributes. This includes offense, guts boost, and miss rate.
Keep Type: Equipment will keep the character that was originally able to use it. If Progressive Weapons is enabled, you will get weapons with progressively higher offense.
Chaos: Equipment will be able to be equipped by a randomly selected character. It will try to respect the offense progresively, but the type may not match the type recieved.
The Tee Ball Bat will always be a weapon for Ness."""
display_name = "Weaponizer"
option_off = 0
option_keep_type = 1
option_chaos = 2
default = 0
class ElementChance(Range):
"""Percent chance for any given Body/Other equipment to have elemental protection.
Affects Armorizer only."""
display_name = "Elemental Resistance Chance"
range_start = 1
range_end = 50
default = 15
class NoFreeSancs(Toggle):
"""If enabled, the entrance to Lilliput Steps and Fire Spring will be locked and require extra key items to access.
These items are the Tiny Key and Tenda Lavapants, respectively."""
display_name = "No Free Sanctuaries"
class RandomizeFanfares(Choice):
"""Randomizes fanfares."""
display_name = "Randomize Fanfares"
option_off = 0
option_on = 1
option_on_no_sound_stone_fanfares = 2
default = 0
class RandomizeBattleMusic(Toggle):
"""Randomizes in-battle songs."""
display_name = "Randomize Battle Music"
class RandomizeOverworldMusic(Choice):
"""Randomizes music on the overworld. Some sound effects might sound weird.
Normal: Does not randomize music.
Match Type: Music will be randomized with similar song categories (Town, dungeon, etc.)
Full: Overworld music will be randomized disregarding categories."""
display_name = "Overworld Music Randomizer"
option_normal = 0
option_match_type = 1
option_full = 2
default = 0
class RandomizePSIPalettes(Choice):
"""Randomizes the colors of PSI spells.
Normal: Doesn't randomize PSI colors.
Shuffled: PSI spell palettes are swapped around with each other.
Randomized: PSI spells use completely random colors."""
display_name = "Random PSI Palettes"
option_normal = 0
option_shuffled = 1
option_randomized = 2
default = 0
class ShopRandomizer(Choice):
"""Randomizes items in shops.
Local Filler: Shops contain only random items for yourself and are not checks.
Shopsanity. Every shop slot in the game contains a Multiworld location. ONLY ENABLE SHOPSANITY IF YOU KNOW WHAT YOU ARE DOING."""
display_name = "Shop Randomizer"
option_off = 0
option_local_filler = 1
option_shopsanity = 2
default = 0
class ScoutShopChecks(Choice):
"""Scouts Shop checks when you open a shop. Only affects shops in Shopsanity mode."""
display_name = "Scout Shop Checks"
option_off = 0
option_progression_only = 1
option_all = 2
default = 1
class StartingCharacter(Choice):
"""Sets which character you start as. Each character will always start with the ability to teleport,
and the ATM card. Ness will not be required to fight Sanctuary bosses."""
display_name = "Starting Character"
option_Ness = 0
option_Paula = 1
option_Jeff = 2
option_Poo = 3
default = 0
class EquipamizerStatCap(DefaultOnToggle):
"""If enabled, the highest value that Equipamizer can roll for a piece of equipment's
main stat will be capped. 80 for armor, 125 for weapons.
If disabled, the main stat can potentially roll up to 128."""
display_name = "Equipamizer Stat Cap"
class MoneyDropMultiplier(Range):
"""Multiplies money dropped by enemies by the chosen value."""
display_name = "Money Drop Multiplier"
range_start = 1
range_end = 100
default = 1
class EnemyShuffle(Toggle):
"""Shuffles Non-boss enemies amongst each other."""
display_name = "Enemy Shuffle"
class SkipEpilogue(Toggle):
"""If enabled, the choice to play the epilogue after beating Giygas will be removed, and you will
go directly to the credits. This option is mainly for no-release seeds where checks could be
potentially spoiled in the open-access epilogue."""
display_name = "Skip Epilogue"
visibility = Visibility.template
class EnergyLink(Toggle):
"""If enabled, the money in the ATM will be linked across the Archipelago Server.
This requires a server connection to be used, but won't break offline play."""
display_name = "Energy Link"
class DungeonShuffle(Toggle):
"""Shuffles Dungeon entrances amongst each other."""
display_name = "Dungeon Shuffle"
class PhotoCount(Range):
"""How many Photograph traps are placed in the item pool."""
display_name = "Photos in pool"
range_start = 0
range_end = 32
default = 20
class EasyCombat(Toggle):
"""Automatically halves all scaled enemy levels."""
display_name = "Easy Combat"
class EnemizerStats(Toggle):
"""Randomizes base stats and level of non-boss enemies."""
display_name = "Randomize Enemy Stats"
class EnemizerAttacks(Toggle):
"""Randomizes attacks of non-boss enemies."""
display_name = "Randomize Enemy Attacks"
class EnemizerAttributes(Toggle):
"""Randomizes most attributes of non-boss enemies."""
display_name = "Randomize Enemy Attributes"
class RandomMapColors(Choice):
"""Randomizes map colors.
Normal: Uses normal colors
Nice: Uses generally good looking palettes for maps with little artifacting.
Ugly: Allows map palettes with artifacting or colors that may not look good.
Nonsense: Allows really bad palettes or heavy artifacting."""
display_name = "Shuffle Map Palettes"
option_normal = 0
option_nice = 1
option_ugly = 2
option_nonsense = 3
default = 0
class SafeFinalBoss(DefaultOnToggle):
"""Prevents specific difficult bosses from being randomized onto Heavily Armed Pokey's boss slot.
Only affects Boss Shuffle, and does not affect Phase 2 Giygas if Boss Shuffle Add Giygas is enabled."""
display_name = "Safe Final Boss"
@dataclass
class EBOptions(PerGameCommonOptions):
giygas_required: GiygasRequired
sanctuaries_required: SanctuariesRequired
skip_prayer_sequences: ShortenPrayers
random_start_location: RandomStartLocation
alternate_sanctuary_goal: SanctuaryAltGoal
magicant_mode: MagicantMode
monkey_caves_mode: MonkeyCavesMode
local_teleports: LocalTeleports
character_shuffle: CharacterShuffle
starting_character: StartingCharacter
psi_shuffle: PSIShuffle
allow_flash_as_favorite_thing: BanFlashFavorite
enemy_shuffle: EnemyShuffle
boss_shuffle: BossShuffle
decouple_diamond_dog: DecoupleDiamondDog
boss_shuffle_add_giygas: ShuffleGiygas
safe_final_boss: SafeFinalBoss
randomize_enemy_attributes: EnemizerAttributes
randomize_enemy_stats: EnemizerStats
randomize_enemy_attacks: EnemizerAttacks
experience_modifier: ExperienceModifier
money_drop_multiplier: MoneyDropMultiplier
starting_money: StartingMoney
easy_deaths: EasyDeaths
easy_combat: EasyCombat
progressive_weapons: ProgressiveWeapons
progressive_armor: ProgressiveArmor
armorizer: Armorizer
weaponizer: Weaponizer
armorizer_resistance_chance: ElementChance
equipamizer_cap_stats: EquipamizerStatCap
auto_scale_party_members: AutoscaleParty
remote_items: RemoteItems
random_flavors: RandomFlavors
random_battle_backgrounds: RandomBattleBG
random_swirl_colors: RandomSwirlColors
presents_match_contents: PresentSprites
nonlocal_items_use_local_presents: NoAPPresents
prefixed_items: PreFixItems
total_photos: PhotoCount
randomize_franklinbadge_protection: RandomFranklinBadge
shuffle_enemy_drops: ShuffleDrops
common_filler_weight: CommonWeight
uncommon_filler_weight: UncommonWeight
rare_filler_weight: RareWeight
money_weight: MoneyWeight
plando_lumine_hall_text: PlandoLumineHallText
no_free_sanctuaries: NoFreeSancs
randomize_overworld_music: RandomizeOverworldMusic
randomize_battle_music: RandomizeBattleMusic
randomize_fanfares: RandomizeFanfares
randomize_psi_palettes: RandomizePSIPalettes
map_palette_shuffle: RandomMapColors
shop_randomizer: ShopRandomizer
scout_shop_checks: ScoutShopChecks
dungeon_shuffle: DungeonShuffle
skip_epilogue: SkipEpilogue
start_inventory_from_pool: StartInventoryPool
death_link: DeathLink
death_link_mode: DeathLinkMode
energy_link: EnergyLink
eb_option_groups = [
OptionGroup("Goal Settings", [
GiygasRequired,
SanctuariesRequired,
SanctuaryAltGoal
]),
OptionGroup("Item Settings", [
LocalTeleports,
CharacterShuffle,
ProgressiveWeapons,
ProgressiveArmor,
RandomFranklinBadge,
CommonWeight,
UncommonWeight,
RareWeight,
MoneyWeight,
PreFixItems,
PhotoCount
]),
OptionGroup("Equipamizer", [
Armorizer,
Weaponizer,
ElementChance,
EquipamizerStatCap
]),
OptionGroup("World Modes", [
RandomStartLocation,
MagicantMode,
MonkeyCavesMode,
NoFreeSancs,
StartingCharacter
]),
OptionGroup("PSI Randomization", [
PSIShuffle,
BanFlashFavorite
]),
OptionGroup("Enemy Randomization", [
EnemyShuffle,
BossShuffle,
SafeFinalBoss,
DecoupleDiamondDog,
ShuffleGiygas,
ExperienceModifier,
ShuffleDrops,
MoneyDropMultiplier
]),
OptionGroup("Enemizer", [
EnemizerAttributes,
EnemizerAttacks,
EnemizerStats
]),
OptionGroup("Shop Randomization", [
ShopRandomizer,
ScoutShopChecks
]),
OptionGroup("Entrance Randomization", [
DungeonShuffle
]),
OptionGroup("Convenience Settings", [
ShortenPrayers,
EasyDeaths,
StartingMoney,
RemoteItems,
AutoscaleParty,
SkipEpilogue,
EasyCombat
]),
OptionGroup("Aesthetic Settings", [
RandomFlavors,
RandomSwirlColors,
RandomBattleBG,
RandomMapColors,
PresentSprites,
NoAPPresents,
RandomizePSIPalettes,
PlandoLumineHallText
]),
OptionGroup("Music Randomizer", [
RandomizeOverworldMusic,
RandomizeBattleMusic,
RandomizeFanfares
]),
OptionGroup("Multiplayer Features", [
DeathLink,
DeathLinkMode,
EnergyLink
])
]

View File

@@ -0,0 +1,279 @@
from typing import List, Dict, TYPE_CHECKING, Optional
from BaseClasses import Region, Location
from .Locations import LocationData
from .Options import MagicantMode
if TYPE_CHECKING:
from . import EarthBoundWorld
class EBLocation(Location):
game: str = "EarthBound"
def init_areas(world: "EarthBoundWorld", locations: list[LocationData]) -> None:
multiworld = world.multiworld
player = world.player
locations_per_region = get_locations_per_region(locations)
regions = [
create_region(world, player, locations_per_region, "Menu"),
create_region(world, player, locations_per_region, "Ness's Mind"),
create_region(world, player, locations_per_region, "Global ATM Access"),
create_region(world, player, locations_per_region, "Northern Onett"),
create_region(world, player, locations_per_region, "Onett"),
create_region(world, player, locations_per_region, "Arcade"),
create_region(world, player, locations_per_region, "Giant Step"),
create_region(world, player, locations_per_region, "Twoson"),
create_region(world, player, locations_per_region, "Common Condiment Shop"),
create_region(world, player, locations_per_region, "Everdred's House"),
create_region(world, player, locations_per_region, "Peaceful Rest Valley"),
create_region(world, player, locations_per_region, "Happy-Happy Village"),
create_region(world, player, locations_per_region, "Happy-Happy HQ"),
create_region(world, player, locations_per_region, "Lilliput Steps"),
create_region(world, player, locations_per_region, "Threed"),
create_region(world, player, locations_per_region, "Threed Underground"),
create_region(world, player, locations_per_region, "Boogey Tent"),
create_region(world, player, locations_per_region, "Grapefruit Falls"),
create_region(world, player, locations_per_region, "Belch's Factory"),
create_region(world, player, locations_per_region, "Saturn Valley"),
create_region(world, player, locations_per_region, "Upper Saturn Valley"),
create_region(world, player, locations_per_region, "Milky Well"),
create_region(world, player, locations_per_region, "Dusty Dunes Desert"),
create_region(world, player, locations_per_region, "Gold Mine"),
create_region(world, player, locations_per_region, "Monkey Caves"),
create_region(world, player, locations_per_region, "Fourside"),
create_region(world, player, locations_per_region, "Moonside"),
create_region(world, player, locations_per_region, "Fourside Dept. Store"),
create_region(world, player, locations_per_region, "Magnet Hill"),
create_region(world, player, locations_per_region, "Monotoli Building"),
create_region(world, player, locations_per_region, "Winters"),
create_region(world, player, locations_per_region, "Snow Wood Boarding School"),
create_region(world, player, locations_per_region, "Southern Winters"),
create_region(world, player, locations_per_region, "Brickroad Maze"),
create_region(world, player, locations_per_region, "Rainy Circle"),
create_region(world, player, locations_per_region, "Andonuts Lab Area"),
create_region(world, player, locations_per_region, "Stonehenge Base"),
create_region(world, player, locations_per_region, "Summers"),
create_region(world, player, locations_per_region, "Summers Museum"),
create_region(world, player, locations_per_region, "Dalaam"),
create_region(world, player, locations_per_region, "Pink Cloud"),
create_region(world, player, locations_per_region, "Scaraba"),
create_region(world, player, locations_per_region, "Pyramid"),
create_region(world, player, locations_per_region, "Southern Scaraba"),
create_region(world, player, locations_per_region, "Dungeon Man"),
create_region(world, player, locations_per_region, "Deep Darkness"),
create_region(world, player, locations_per_region, "Deep Darkness Darkness"),
create_region(world, player, locations_per_region, "Tenda Village"),
create_region(world, player, locations_per_region, "Lumine Hall"),
create_region(world, player, locations_per_region, "Lost Underworld"),
create_region(world, player, locations_per_region, "Fire Spring"),
create_region(world, player, locations_per_region, "Magicant"),
create_region(world, player, locations_per_region, "Sea of Eden"),
create_region(world, player, locations_per_region, "Cave of the Present")
]
if world.options.giygas_required:
regions.extend([
create_region(world, player, locations_per_region, "Cave of the Past"),
create_region(world, player, locations_per_region, "Endgame")
])
multiworld.regions += regions
def connect_area_exits(world: "EarthBoundWorld"):
multiworld = world.multiworld
player = world.player
connect_menu_region(world)
arcade_connection = world.dungeon_connections["Arcade"]
giant_step_connection = world.dungeon_connections["Giant Step"]
lilliput_steps_connection = world.dungeon_connections["Lilliput Steps"]
happy_happy_hq_connection = world.dungeon_connections["Happy-Happy HQ"]
belch_factory_connection = world.dungeon_connections["Belch's Factory"]
milky_well_connection = world.dungeon_connections["Milky Well"]
gold_mine_connection = world.dungeon_connections["Gold Mine"]
moonside_connection = world.dungeon_connections["Moonside"]
monotoli_building_connection = world.dungeon_connections["Monotoli Building"]
magnet_hill_connection = world.dungeon_connections["Magnet Hill"]
pink_cloud_connection = world.dungeon_connections["Pink Cloud"]
pyramid_connection = world.dungeon_connections["Pyramid"]
dungeon_man_connection = world.dungeon_connections["Dungeon Man"]
rainy_circle_connection = world.dungeon_connections["Rainy Circle"]
stonehenge_connection = world.dungeon_connections["Stonehenge Base"]
lumine_hall_connection = world.dungeon_connections["Lumine Hall"]
fire_spring_connection = world.dungeon_connections["Fire Spring"]
sea_of_eden_connection = world.dungeon_connections["Sea of Eden"]
brickroad_maze_connection = world.dungeon_connections["Brickroad Maze"]
multiworld.get_region("Ness's Mind", player).add_exits(["Onett", "Twoson", "Happy-Happy Village", "Threed", "Saturn Valley", "Dusty Dunes Desert", "Fourside", "Winters", "Summers", "Dalaam", "Scaraba", "Deep Darkness", "Tenda Village", "Lost Underworld", "Magicant"],
{"Onett": lambda state: state.has("Onett Teleport", player),
"Twoson": lambda state: state.has("Twoson Teleport", player),
"Happy-Happy Village": lambda state: state.has("Happy-Happy Village Teleport", player),
"Threed": lambda state: state.has("Threed Teleport", player),
"Saturn Valley": lambda state: state.has("Saturn Valley Teleport", player),
"Dusty Dunes Desert": lambda state: state.has("Dusty Dunes Teleport", player),
"Fourside": lambda state: state.has("Fourside Teleport", player),
"Winters": lambda state: state.has("Winters Teleport", player),
"Summers": lambda state: state.has("Summers Teleport", player),
"Dalaam": lambda state: state.has("Dalaam Teleport", player),
"Scaraba": lambda state: state.has("Scaraba Teleport", player),
"Deep Darkness": lambda state: state.has("Deep Darkness Teleport", player),
"Tenda Village": lambda state: state.has("Tenda Village Teleport", player),
"Lost Underworld": lambda state: state.has("Lost Underworld Teleport", player),
"Magicant": lambda state: state.has_any({"Magicant Teleport", "Magicant Unlock"}, player)})
multiworld.get_region("Northern Onett", player).add_exits(["Onett"])
multiworld.get_region("Onett", player).add_exits([giant_step_connection, "Twoson", "Northern Onett", "Global ATM Access", arcade_connection],
{giant_step_connection: lambda state: state.has("Key to the Shack", player),
"Twoson": lambda state: state.has("Police Badge", player),
"Northern Onett": lambda state: state.has("Police Badge", player)})
multiworld.get_region("Twoson", player).add_exits(["Onett", "Peaceful Rest Valley", "Threed", "Everdred's House", "Global ATM Access", "Common Condiment Shop"],
{"Onett": lambda state: state.has("Police Badge", player),
"Peaceful Rest Valley": lambda state: state.has_any({"Pencil Eraser", "Valley Bridge Repair"}, player),
"Threed": lambda state: state.has_any({"Threed Tunnels Clear", "Wad of Bills"}, player),
"Everdred's House": lambda state: state.has("Paula", player)})
multiworld.get_region("Peaceful Rest Valley", player).add_exits(["Twoson", "Happy-Happy Village"],
{"Twoson": lambda state: state.has_any({"Pencil Eraser", "Valley Bridge Repair"}, player)})
multiworld.get_region("Happy-Happy Village", player).add_exits(["Peaceful Rest Valley", lilliput_steps_connection, "Global ATM Access", happy_happy_hq_connection])
multiworld.get_region("Threed", player).add_exits(["Twoson", "Dusty Dunes Desert", "Andonuts Lab Area", "Threed Underground", "Boogey Tent", "Global ATM Access"],
{"Twoson": lambda state: state.has("Threed Tunnels Clear", player),
"Dusty Dunes Desert": lambda state: state.has("Threed Tunnels Clear", player),
"Andonuts Lab Area": lambda state: state.has_all({"UFO Engine", "Bad Key Machine"}, player),
"Threed Underground": lambda state: state.has("Zombie Paper", player),
"Boogey Tent": lambda state: state.has("Jeff", player)})
multiworld.get_region("Threed Underground", player).add_exits(["Grapefruit Falls"])
multiworld.get_region("Grapefruit Falls", player).add_exits([belch_factory_connection, "Saturn Valley", "Threed Underground"],
{belch_factory_connection: lambda state: state.has("Jar of Fly Honey", player)})
multiworld.get_region(belch_factory_connection, player).add_exits(["Upper Saturn Valley"],
{"Upper Saturn Valley": lambda state: state.has("Threed Tunnels Clear", player)})
multiworld.get_region("Saturn Valley", player).add_exits(["Grapefruit Falls", "Cave of the Present", "Global ATM Access"],
{"Cave of the Present": lambda state: state.has("Meteorite Piece", player)})
multiworld.get_region("Upper Saturn Valley", player).add_exits([milky_well_connection, "Saturn Valley"])
multiworld.get_region("Dusty Dunes Desert", player).add_exits(["Threed", "Monkey Caves", gold_mine_connection, "Fourside", "Global ATM Access"],
{"Threed": lambda state: state.has("Threed Tunnels Clear", player),
"Monkey Caves": lambda state: state.has("King Banana", player),
gold_mine_connection: lambda state: state.has("Mining Permit", player)})
multiworld.get_region("Fourside", player).add_exits(["Dusty Dunes Desert", monotoli_building_connection, magnet_hill_connection, "Threed", "Fourside Dept. Store", "Global ATM Access", moonside_connection],
{monotoli_building_connection: lambda state: state.has("Yogurt Dispenser", player),
magnet_hill_connection: lambda state: state.has("Signed Banana", player),
"Threed": lambda state: state.has("Diamond", player),
"Fourside Dept. Store": lambda state: state.has("Jeff", player)})
multiworld.get_region("Moonside", player).add_exits(["Global ATM Access"])
multiworld.get_region("Summers", player).add_exits(["Scaraba", "Summers Museum", "Global ATM Access"],
{"Summers Museum": lambda state: state.has("Tiny Ruby", player)})
multiworld.get_region("Winters", player).add_exits(["Snow Wood Boarding School", "Southern Winters", "Global ATM Access"],
{"Snow Wood Boarding School": lambda state: state.has("Letter For Tony", player),
"Southern Winters": lambda state: state.has("Pak of Bubble Gum", player)})
multiworld.get_region("Southern Winters", player).add_exits([brickroad_maze_connection])
multiworld.get_region(brickroad_maze_connection, player).add_exits(["Southern Winters", rainy_circle_connection])
multiworld.get_region(rainy_circle_connection, player).add_exits([brickroad_maze_connection, "Andonuts Lab Area"])
multiworld.get_region("Andonuts Lab Area", player).add_exits([stonehenge_connection, "Winters", rainy_circle_connection],
{stonehenge_connection: lambda state: state.has("Eraser Eraser", player)})
multiworld.get_region("Dalaam", player).add_exits([pink_cloud_connection],
{pink_cloud_connection: lambda state: state.has("Carrot Key", player)})
multiworld.get_region("Scaraba", player).add_exits([pyramid_connection, "Global ATM Access", "Common Condiment Shop"],
{pyramid_connection: lambda state: state.has("Hieroglyph Copy", player)})
multiworld.get_region(pyramid_connection, player).add_exits(["Southern Scaraba"])
multiworld.get_region("Southern Scaraba", player).add_exits([dungeon_man_connection],
{dungeon_man_connection: lambda state: state.has_any({"Key to the Tower"}, player)})
multiworld.get_region("Dungeon Man", player).add_exits(["Deep Darkness"],
{"Deep Darkness": lambda state: state.has("Submarine to Deep Darkness", player)})
multiworld.get_region("Deep Darkness", player).add_exits(["Deep Darkness Darkness"],
{"Deep Darkness Darkness": lambda state: state.has("Hawk Eye", player)})
multiworld.get_region("Deep Darkness Darkness", player).add_exits(["Tenda Village", "Deep Darkness"])
multiworld.get_region("Tenda Village", player).add_exits([lumine_hall_connection, "Deep Darkness Darkness"],
{lumine_hall_connection: lambda state: state.has("Shyness Book", player),
"Deep Darkness Darkness": lambda state: state.has_all({"Shyness Book", "Hawk Eye"}, player)})
multiworld.get_region("Lumine Hall", player).add_exits(["Lost Underworld"])
multiworld.get_region("Lost Underworld", player).add_exits([fire_spring_connection])
if world.options.giygas_required:
multiworld.get_region("Cave of the Present", player).add_exits(["Cave of the Past"],
{"Cave of the Past": lambda state: state.has("Power of the Earth", player)})
multiworld.get_region("Cave of the Past", player).add_exits(["Endgame"],
{"Endgame": lambda state: state.has("Paula", player)})
if world.options.magicant_mode < MagicantMode.option_optional_boost: # 3
multiworld.get_region("Magicant", player).add_exits(["Global ATM Access", sea_of_eden_connection],
{sea_of_eden_connection: lambda state: state.has("Ness", player)})
def create_location(player: int, location_data: LocationData, region: Region) -> Location:
location = EBLocation(player, location_data.name, location_data.code, region)
return location
def create_region(world: "EarthBoundWorld", player: int, locations_per_region: Dict[str, List[LocationData]], name: str) -> Region:
region = Region(name, player, world.multiworld)
if name in locations_per_region:
for location_data in locations_per_region[name]:
location = create_location(player, location_data, region)
region.locations.append(location)
return region
def get_locations_per_region(locations: List[LocationData]) -> Dict[str, List[LocationData]]:
per_region: Dict[str, List[LocationData]] = {}
for location in locations:
per_region.setdefault(location.region, []).append(location)
return per_region
def connect_menu_region(world: "EarthBoundWorld") -> None:
starting_region_list = {
0: "Northern Onett",
1: "Onett",
2: "Twoson",
3: "Happy-Happy Village",
4: "Threed",
5: "Saturn Valley",
6: "Fourside",
7: "Winters",
8: "Summers",
9: "Dalaam",
10: "Scaraba",
11: "Deep Darkness",
12: "Tenda Village",
13: "Lost Underworld",
14: "Magicant"
}
world.starting_region = starting_region_list[world.start_location]
world.multiworld.get_region("Menu", world.player).add_exits([world.starting_region, "Ness's Mind"],
{"Ness's Mind": lambda state: state.has_any({"Ness", "Paula", "Jeff", "Poo"}, world.player),
world.starting_region: lambda state: state.has_any({"Ness", "Paula", "Jeff", "Poo"}, world.player)})

848
worlds/earthbound/Rom.py Normal file
View File

@@ -0,0 +1,848 @@
import hashlib
import os
import Utils
import typing
import struct
import settings
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension
from .game_data import local_data
from .game_data.battle_bg_data import battle_bg_bpp
from .modules.psi_shuffle import write_psi
from .game_data.text_data import barf_text, text_encoder
from .modules.flavor_data import flavor_data, vanilla_flavor_pointers
from .modules.hint_data import parse_hint_data
from .modules.enemy_data import scale_enemies
from .modules.area_scaling import calculate_scaling
from .modules.boss_shuffle import write_bosses
from .modules.equipamizer import randomize_armor, randomize_weapons
from .modules.music_rando import music_randomizer
from .modules.palette_shuffle import randomize_psi_palettes, map_palette_shuffle
from .modules.shopsanity import write_shop_checks
from .modules.enemy_shuffler import apply_enemy_shuffle
from .modules.dungeon_er import write_dungeon_entrances
# from .modules.foodamizer import randomize_food
from .modules.enemizer.randomize_enemy_attributes import randomize_enemy_attributes
from .modules.enemizer.randomize_enemy_stats import randomize_enemy_stats
from .modules.enemizer.randomize_enemy_attacks import randomize_enemy_attacks
from .game_data.static_location_data import location_groups
from BaseClasses import ItemClassification
from typing import TYPE_CHECKING, Sequence
from logging import warning
# from .local_data import local_locations
if TYPE_CHECKING:
from . import EarthBoundWorld
item_id_table = local_data.item_id_table
location_dialogue = local_data.location_dialogue
present_locations = local_data.present_locations
psi_locations = local_data.psi_locations
npc_locations = local_data.npc_locations
character_locations = local_data.character_locations
special_name_table = local_data.special_name_table
item_space_checks = local_data.item_space_checks
local_present_types = local_data.local_present_types
present_text_pointers = local_data.present_text_pointers
psi_item_table = local_data.psi_item_table
character_item_table = local_data.character_item_table
party_id_nums = local_data.party_id_nums
starting_psi_table = local_data.starting_psi_table
badge_names = local_data.badge_names
world_version = local_data.world_version
protection_checks = local_data.protection_checks
protection_text = local_data.protection_text
nonlocal_present_types = local_data.nonlocal_present_types
ap_text_pntrs = local_data.ap_text_pntrs
money_item_table = local_data.money_item_table
valid_hashes = ["a864b2e5c141d2dec1c4cbed75a42a85", # Cartridge
"6d71ccc8e2afda15d011348291afdf4f"] # VC
class LocalRom(object):
def __init__(self, file: bytes, name: str | None = None) -> None:
self.file = bytearray(file)
self.name = name
def read_byte(self, offset: int) -> int:
return self.file[offset]
def read_bytes(self, offset: int, length: int) -> bytes:
return self.file[offset:offset + length]
def write_bytes(self, offset: int, values: Sequence[int]) -> None:
self.file[offset:offset + len(values)] = values
def get_bytes(self) -> bytes:
return bytes(self.file)
def patch_rom(world: "EarthBoundWorld", rom: LocalRom, player: int) -> None:
rom.copy_bytes(0x1578DD, 0x3E, 0x34A060) # Threed/Saturn teleport move
rom.copy_bytes(0x15791B, 0xF8, 0x157959)
rom.copy_bytes(0x34A000, 0x1F, 0x1578DD)
rom.copy_bytes(0x34A020, 0x1F, 0x15793A)
rom.copy_bytes(0x34A040, 0x1F, 0x157A51)
rom.copy_bytes(0x34A060, 0x3E, 0x1578FC)
rom.copy_bytes(0x15ED4B, 0x06, 0x15F1FB)
rom.copy_bytes(0x1A7FA7, 0xBF, 0x389900)
starting_area_coordinates = {
0: [0x50, 0x04, 0xB5, 0x1F], # North Onett
1: [0x52, 0x06, 0x4C, 0x1F], # Onett
2: [0xEF, 0x22, 0x41, 0x1F], # Twoson
3: [0x53, 0x06, 0x85, 0x1D], # Happy Happy
4: [0x55, 0x24, 0x69, 0x1D], # Threed
5: [0x60, 0x1D, 0x30, 0x01], # Saturn Valley
6: [0xAB, 0x10, 0xF3, 0x09], # Fourside
7: [0xE3, 0x09, 0xA3, 0x1D], # Winters
8: [0xCB, 0x24, 0x7B, 0x1E], # Summers
9: [0xD0, 0x1E, 0x31, 0x1D], # Dalaam
10: [0xC7, 0x1F, 0x37, 0x19], # Scaraba
11: [0xDD, 0x1B, 0xB7, 0x17], # Deep Darkness
12: [0xD0, 0x25, 0x47, 0x18], # Tenda Village
13: [0x9C, 0x00, 0x84, 0x17], # Lost Underworld
14: [0x4B, 0x11, 0xAD, 0x18] # Magicant
}
starting_levels = {
"Ness": 0x15F5FB,
"Paula": 0x15F60F,
"Jeff": 0x15F623,
"Poo": 0x15F637
}
atm_card_slots = {
"Ness": 0x15F5FF,
"Paula": 0x15F613,
"Jeff": 0x15F629,
"Poo": 0x15F63B
}
starting_weapon = {
"Ness": [0x15F600, 0x12],
"Paula": [0x15F615, 0x1C],
"Jeff": [0x15F62A, 0x24]
}
teleport_learnlevel = {
"Ness": [0x158D53, 0x158D62],
"Paula": [0x158D54, 0x158D63],
"Poo": [0x158D55, 0x158D64]
}
world.start_items = []
world.handled_locations = []
for item in world.multiworld.precollected_items[world.player]:
world.start_items.append(item.name)
if world.options.random_start_location:
rom.write_bytes(0x0F96C2, bytearray([0x69, 0x00]))
rom.write_bytes(0x0F9618, bytearray([0x69, 0x00]))
rom.write_bytes(0x0F9629, bytearray([0x69, 0x00])) # Block Northern Onett
else:
rom.write_bytes(0x00B66A, bytearray([0x06])) # Fix starting direction
rom.write_bytes(0x01FE9B, bytearray(starting_area_coordinates[world.start_location][0:2]))
rom.write_bytes(0x01FE9E, bytearray(starting_area_coordinates[world.start_location][2:4])) # Start position
rom.write_bytes(0x01FE91, bytearray(starting_area_coordinates[world.start_location][0:2]))
rom.write_bytes(0x01FE8B, bytearray(starting_area_coordinates[world.start_location][2:4])) # Respawn position
if world.options.skip_epilogue:
rom.write_bytes(0x09C4D4, struct.pack("I", 0xEEA437))
if world.starting_character == "Poo":
rom.write_bytes(starting_levels[world.starting_character], bytearray([0x06]))
else:
rom.write_bytes(starting_levels[world.starting_character], bytearray([0x03]))
rom.write_bytes(atm_card_slots[world.starting_character], bytearray([0xB1]))
if world.starting_character != "Ness":
rom.write_bytes(atm_card_slots["Ness"], bytearray([0x58]))
if world.starting_character != "Poo":
rom.write_bytes(starting_weapon[world.starting_character][0], bytearray([starting_weapon[world.starting_character][1]]))
if world.starting_character != "Jeff":
for i in range(2):
rom.write_bytes(teleport_learnlevel[world.starting_character][i - 1], bytearray([0x01]))
else:
rom.write_bytes(0x15F62B, bytearray([0xB5]))
if world.options.alternate_sanctuary_goal:
rom.write_bytes(0x04FD72, bytearray([world.options.sanctuaries_required.value + 2]))
else:
rom.write_bytes(0x04FD72, bytearray([0xFF]))
if not world.options.giygas_required:
rom.write_bytes(0x2E9C29, bytearray([0x10, 0xA5]))
if world.options.magicant_mode == 2:
rom.write_bytes(0x04FD71, bytearray([world.options.sanctuaries_required.value + 1]))
rom.write_bytes(0x2EA26A, bytearray([0x0A, 0x10, 0xA5, 0xEE])) # Alt goal magicant sets the credits
elif world.options.magicant_mode == 1:
rom.write_bytes(0x2E9C29, bytearray([0x00, 0xA5]))
if world.options.giygas_required:
rom.write_bytes(0x2EA26A, bytearray([0x08, 0xD9, 0x9B, 0xEE])) # Give stat boost if magicant + giygas required
else:
rom.write_bytes(0x2EA26A, bytearray([0x0A, 0x10, 0xA5, 0xEE])) # If no giygas, set credits
elif world.options.magicant_mode == 3:
rom.write_bytes(0x2EA26A, bytearray([0x08, 0x0F, 0x9C, 0xEE])) # Give only stat boost if set to boost
rom.write_bytes(0x04FD74, bytearray([world.options.death_link.value]))
rom.write_bytes(0x04FD75, bytearray([world.options.death_link_mode.value]))
rom.write_bytes(0x04FD76, bytearray([world.options.remote_items.value]))
rom.write_bytes(0x04FD78, bytearray([world.options.energy_link.value]))
if world.options.death_link_mode != 1:
rom.write_bytes(0x2FFDFE, bytearray([0x80])) # Mercy healing
rom.write_bytes(0x2FFE30, bytearray([0x80])) # Mercy text
rom.write_bytes(0x2FFE59, bytearray([0x80])) # Mercy revive
# IF YOU ADD ASM, CHANGE THESE OR THE GAME WILL CRASH
if world.options.monkey_caves_mode == 2:
rom.write_bytes(0x062B87, bytearray([0x0A, 0x28, 0xCA, 0xEE]))
elif world.options.monkey_caves_mode == 3:
rom.write_bytes(0x0F1388, bytearray([0x03, 0xCA, 0xEE]))
if world.options.no_free_sanctuaries:
rom.write_bytes(0x0F09F2, bytearray([0x15, 0x84])) # Lock Lilliput steps with flag $0415
rom.write_bytes(0x0F09EE, struct.pack("I", 0xEEF790)) # Lilliput door script
rom.write_bytes(0x0F23D2, bytearray([0x16, 0x84])) # Lock Fire Spring with flag $0146
rom.write_bytes(0x0F23CE, struct.pack("I", 0xEEF946)) # Fire Spring door script
rom.write_bytes(0x04FD70, bytearray([world.options.sanctuaries_required.value]))
shop_checks = []
for location in world.multiworld.get_locations(player):
if location.address:
receiver_name = world.multiworld.get_player_name(location.item.player)
name = location.name
if world.options.remote_items:
item = "Remote Item"
else:
item = location.item.name
item_name_loc = (((location.address - 0xEB0000) * 128) + 0x3F0000)
# todo; replace with the encoder function
item_text = text_encoder(location.item.name, 128)
item_text.extend([0x00])
player_name_loc = (((location.address - 0xEB0000) * 48) + 0x3F8000)
player_text = text_encoder(receiver_name, 48)
# Locations over this address are Shopsanity locations and handled in the shopsanity module
if location.address < 0xEB1000:
rom.write_bytes(item_name_loc, bytearray(item_text))
rom.write_bytes(player_name_loc, bytearray(player_text))
if item not in item_id_table or location.item.player != location.player:
item_id = 0xAD
else:
item_id = item_id_table[item]
if name in location_dialogue:
for i in range(len(location_dialogue[name])):
if location.item.player != location.player or item == "Remote Item":
rom.write_bytes(location_dialogue[name][i] - 1, bytearray([0x17, location.address - 0xEB0000]))
elif item in item_id_table:
rom.write_bytes(location_dialogue[name][i], bytearray([item_id]))
elif item in psi_item_table or item in character_item_table:
rom.write_bytes(location_dialogue[name][i] - 1, bytearray([0x16, special_name_table[item][0]]))
elif item in money_item_table:
rom.write_bytes(location_dialogue[name][i] - 1, bytearray([0x16, (0x16 + list(money_item_table).index(item))]))
if name in present_locations:
world.handled_locations.append(name)
if item == "Nothing": # I can change this to "In nothing_table" later todo: make it so nonlocal items do not follow this table
rom.write_bytes(present_locations[name], bytearray([0x00, 0x00, 0x01]))
elif location.item.player != location.player or item == "Remote Item":
rom.write_bytes(present_locations[name], bytearray([item_id, 0x00, 0x00, (location.address - 0xEB0000)]))
elif item in item_id_table:
rom.write_bytes(present_locations[name], bytearray([item_id, 0x00]))
elif item in psi_item_table:
rom.write_bytes(present_locations[name], bytearray([psi_item_table[item], 0x00, 0x02]))
elif item in character_item_table:
rom.write_bytes(present_locations[name], bytearray([character_item_table[item][0], 0x00, 0x03]))
elif item in money_item_table:
rom.write_bytes(present_locations[name], struct.pack("H", money_item_table[item]))
rom.write_bytes(present_locations[name] + 2, bytearray([0x01]))
if name in npc_locations:
world.handled_locations.append(name)
for i in range(len(npc_locations[name])):
if item in item_id_table or location.item.player != location.player or item == "Remote Item":
rom.write_bytes(npc_locations[name][i], bytearray([item_id]))
elif item in psi_item_table or item in character_item_table:
rom.write_bytes(npc_locations[name][i] - 3, bytearray([0x0E, 0x00, 0x0E, (special_name_table[item][0] + 1)]))
rom.write_bytes(npc_locations[name][i] + 2, bytearray([0xA5, 0xAA, 0xEE]))
elif item in money_item_table:
rom.write_bytes(npc_locations[name][i] - 3, bytearray([0x1D, 0x25]))
rom.write_bytes(npc_locations[name][i] - 1, struct.pack("H", money_item_table[item]))
rom.write_bytes(npc_locations[name][i] + 2, bytearray([0x00, 0xF0, 0xF3]))
if name in psi_locations:
world.handled_locations.append(name)
if item in special_name_table and location.item.player == location.player and item != "Remote Item":
rom.write_bytes(psi_locations[name][0], special_name_table[item][1].to_bytes(3, byteorder="little"))
rom.write_bytes(psi_locations[name][0] + 4, bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
elif item in money_item_table and location.item.player == location.player:
rom.write_bytes(psi_locations[name][0] - 1, bytearray([0x1D, 0x25]))
rom.write_bytes(psi_locations[name][0] + 1, struct.pack("H", money_item_table[item]))
rom.write_bytes(psi_locations[name][0] + 3, bytearray([0x08, 0x26, 0xF0, 0xF3, 0x00, 0x03, 0x00]))
else:
rom.write_bytes(psi_locations[name][0], bytearray(psi_locations[name][1:4]))
rom.write_bytes(psi_locations[name][4], bytearray([item_id]))
if name in character_locations:
world.handled_locations.append(name)
if item in character_item_table and location.item.player == location.player and item != "Remote Item":
rom.write_bytes(character_locations[name][0], special_name_table[item][1].to_bytes(3, byteorder="little"))
if name == "Snow Wood - Bedroom": # Use lying down sprites for the bedroom check
rom.write_bytes(character_locations[name][1], struct.pack("H", character_item_table[item][2]))
rom.write_bytes(0x0FB0D8, bytearray([0x06]))
else:
rom.write_bytes(character_locations[name][1], struct.pack("H", character_item_table[item][1]))
elif item in psi_item_table and location.item.player == location.player:
rom.write_bytes(character_locations[name][0], (special_name_table[item][1] + 1).to_bytes(3, byteorder="little"))
rom.write_bytes(character_locations[name][1], bytearray([0x62]))
rom.write_bytes(character_locations[name][2], bytearray([0x70, 0xF9, 0xD5]))
elif item in money_item_table and location.item.player == location.player:
rom.write_bytes(character_locations[name][2] - 1, bytearray([0x1D, 0x25]))
rom.write_bytes(character_locations[name][2] + 1, struct.pack("H", money_item_table[item]))
rom.write_bytes(character_locations[name][0] - 2, bytearray([0x01]))
rom.write_bytes(character_locations[name][0], bytearray([0x4B, 0xF0, 0xF3]))
rom.write_bytes(character_locations[name][1], bytearray([0x97]))
else:
rom.write_bytes(character_locations[name][0], bytearray(character_locations[name][4:7]))
if location.item.name in ["Ness", "Paula", "Jeff", "Poo"]:
rom.write_bytes(character_locations[name][1], bytearray([character_item_table[location.item.name][1]]))
else:
rom.write_bytes(character_locations[name][1], bytearray([0x97]))
rom.write_bytes(character_locations[name][2], bytearray([0x18, 0xF9, 0xD5]))
rom.write_bytes(character_locations[name][3], bytearray([item_id]))
if name == "Deep Darkness - Barf Character":
if item in character_item_table:
rom.write_bytes(0x2EA0E2, bytearray(barf_text[item][0:3]))
rom.write_bytes(0x2EA0E8, bytearray(barf_text[item][3:6]))
elif item in psi_item_table and location.item.player == location.player:
rom.write_bytes(0x2EA0E2, bytearray([0x98, 0xC3, 0xEE]))
rom.write_bytes(0x2EA0E8, bytearray([0xF7, 0xC4, 0xEE]))
else:
rom.write_bytes(0x2EA0E2, bytearray([0x6A, 0xC3, 0xEE]))
rom.write_bytes(0x2EA0E8, bytearray([0xB4, 0xC4, 0xEE]))
if name == "Poo - Starting Item":
world.handled_locations.append(name)
if item in item_id_table and location.item.player == location.player and item != "Remote Item":
rom.write_bytes(0x15F63C, bytearray([item_id]))
else:
rom.write_bytes(0x15F63C, bytearray([0x00])) # Don't give anything if the item doesn't have a tangible ID
if item in special_name_table and location.item.player == location.player: # Apply a special script if teleport or character
rom.write_bytes(0x15F765, special_name_table[item][1].to_bytes(3, byteorder="little")) # This might be offset, check if it is
rom.write_bytes(0x2EC618, bytearray([(special_name_table[item][0] + 1)]))
rom.write_bytes(0x2EC61A, bytearray([0xA5, 0xAA, 0xEE]))
rom.write_bytes(0x2EC613, bytearray([0x03, 0x01]))
if item in money_item_table and location.item.player == location.player:
rom.write_bytes(0x15F764, bytearray([0x1D, 0x08]))
rom.write_bytes(0x15F766, struct.pack("H", money_item_table[item]))
rom.write_bytes(0x15F768, bytearray([0x01]))
if location.address >= 0xEB1000:
world.handled_locations.append(name)
shop_checks.append(location)
if name not in world.handled_locations:
warning(f"{name} not placed in {world.multiworld.get_player_name(world.player)}'s EarthBound world. Something went wrong here.")
if name in item_space_checks:
if item not in item_id_table or location.item.player != location.player:
if len(item_space_checks[name]) == 4:
rom.write_bytes(item_space_checks[name][0], bytearray(item_space_checks[name][1:4]))
else:
rom.write_bytes(item_space_checks[name][0], bytearray(item_space_checks[name][1:4]))
rom.write_bytes(item_space_checks[name][4], bytearray(item_space_checks[name][5:8]))
if name in present_locations and "Lost Underworld" not in name and world.options.presents_match_contents:
if ItemClassification.trap in location.item.classification:
world.present_type = "trap"
elif ItemClassification.progression in location.item.classification:
world.present_type = "progression"
elif ItemClassification.useful in location.item.classification:
world.present_type = "useful"
else:
world.present_type = "filler"
if location.item.player == world.player or world.options.nonlocal_items_use_local_presents:
rom.write_bytes(present_locations[name] - 12, bytearray(local_present_types[world.present_type]))
if name != "Threed - Boogey Tent Trashcan":
rom.write_bytes(present_locations[name] - 4, bytearray(present_text_pointers[world.present_type]))
else:
rom.write_bytes(present_locations[name] - 12, bytearray(nonlocal_present_types[world.present_type]))
if name != "Threed - Boogey Tent Trashcan":
if world.present_type == "progression":
rom.write_bytes(present_locations[name] - 4, struct.pack("I", world.random.choice(ap_text_pntrs)))
elif world.present_type == "trap":
rom.write_bytes(present_locations[name] - 4, bytearray([0x8D, 0xce, 0xee]))
else:
rom.write_bytes(present_locations[name] - 4, bytearray([0xc1, 0xcd, 0xee]))
location = world.multiworld.get_location("Twoson - Bike Shop Rental", world.player)
if location.item.name in item_id_table:
item_id = item_id_table[location.item.name]
else:
item_id = 0xAD
rom.write_bytes(0x0800C4, bytearray([item_id])) # Bike shop
rom.write_bytes(0x0802EA, bytearray([item_id]))
rom.write_bytes(0x2EA05C, bytearray([item_id_table[world.slime_pile_wanted_item]]))
rom.write_bytes(0x2F61F6, bytearray([item_id_table[world.slime_pile_wanted_item]]))
hintable_locations = [
location for location in world.multiworld.get_locations()
if location.player == world.player or location.item.player == world.player
]
world.hint_pointer = 0x0000
world.hint_number = 0
for index, hint in enumerate(world.in_game_hint_types):
if hint == "item_at_location":
for location in hintable_locations:
if location.name == world.hinted_locations[index] and location.player == world.player:
parse_hint_data(world, location, rom, hint, index)
elif hint == "region_progression_check":
world.progression_count = 0
for location in hintable_locations:
if location.name in location_groups[world.hinted_regions[index]] and location.player == world.player:
if ItemClassification.progression in location.item.classification:
world.progression_count += 1
world.hinted_area = world.hinted_regions[index] # im doing a little sneaky
parse_hint_data(world, location, rom, hint, index)
elif hint == "hint_for_good_item" or hint == "prog_item_at_region":
hintable_locations_2 = []
for location in hintable_locations:
if location.item.name == world.hinted_items[index] and location.item.player == world.player:
hintable_locations_2.append(location)
if hintable_locations_2 == []:
# This is just failsafe behavior
warning(f"Warning: Unable to create local hint for {world.hinted_items[index]} for "
+ f"{world.multiworld.get_player_name(world.player)}'s EarthBound world."
+ " Please report this.")
location = world.random.choice(hintable_locations)
else:
location = world.random.choice(hintable_locations_2)
parse_hint_data(world, location, rom, hint, index)
elif hint == "item_in_local_region":
for location in hintable_locations:
if location.name == world.hinted_locations[index] and location.player == world.player:
parse_hint_data(world, location, rom, hint, index)
else:
location = "null"
parse_hint_data(world, location, rom, hint, index)
for location in hintable_locations:
if location.item.name == "Paula":
world.paula_region = location.parent_region
if location.item.name == "Jeff":
world.jeff_region = location.parent_region
if location.item.name == "Poo":
world.poo_region = location.parent_region
if world.options.skip_prayer_sequences:
rom.write_bytes(0x07BC96, bytearray([0x02]))
rom.write_bytes(0x07BA2C, bytearray([0x02]))
rom.write_bytes(0x07BAC7, bytearray([0x02]))
rom.write_bytes(0x07BB38, bytearray([0x02]))
rom.write_bytes(0x07BBF3, bytearray([0x02]))
rom.write_bytes(0x07BC56, bytearray([0x02]))
rom.write_bytes(0x07B9A1, bytearray([0x1f, 0xeb, 0xff, 0x02, 0x1f, 0x1f, 0xca, 0x01, 0x06, 0x1f, 0x1f, 0x72, 0x01, 0x06, 0x02])) # Clean up overworld stuff
if world.options.easy_deaths:
rom.write_bytes(0x2EBFF9, bytearray([0x0A]))
rom.write_bytes(0x04C7CE, bytearray([0x5C, 0x8A, 0xFB, 0xEF])) # Jump to code that restores the party
rom.write_bytes(0x04C7D4, bytearray([0xEA, 0xEA, 0xEA]))
# rom.write_bytes(0x04C7DA, bytearray([0xEA, 0xEA]))#Stop the game from zeroing stuff
rom.write_bytes(0x0912F2, bytearray([0x0A, 0xFE, 0xBF, 0xEE]))
rom.write_bytes(0x2EBFFE, bytearray([0x00, 0x1B, 0x04, 0x15, 0x38, 0x1F, 0x81, 0xFF, 0xFF, 0x1B, 0x04, 0x0A, 0xF7, 0x12, 0xC9])) # Hospitals = 0$
rom.write_bytes(0x04C822, bytearray([0xEA, 0xEA, 0xEA, 0xEA]))
rom.write_bytes(0x04C7F7, bytearray([0x00, 0x00])) # Stop "rounding up" the money
if world.options.magicant_mode >= 2:
rom.write_bytes(0x077629, bytearray([item_id_table[world.magicant_junk[0]]]))
rom.write_bytes(0x077614, bytearray([item_id_table[world.magicant_junk[0]]]))
rom.write_bytes(0x0FF25C, bytearray([item_id_table[world.magicant_junk[1]]]))
rom.write_bytes(0x0FF27E, bytearray([item_id_table[world.magicant_junk[2]]]))
rom.write_bytes(0x0FF28F, bytearray([item_id_table[world.magicant_junk[3]]]))
rom.write_bytes(0x0FF2A0, bytearray([item_id_table[world.magicant_junk[4]]]))
rom.write_bytes(0x0FF26D, bytearray([item_id_table[world.magicant_junk[5]]]))
rom.write_bytes(0x02EC1AA, bytearray([world.options.sanctuaries_required.value]))
if world.options.alternate_sanctuary_goal and world.options.giygas_required:
rom.write_bytes(0x02EC1E2, bytearray([0xFD, 0xC1, 0xEE]))
if world.options.magicant_mode == 1 and world.options.giygas_required: # Apple kid text
rom.write_bytes(0x2EC1D8, bytearray([0x33, 0xC2, 0xEE]))
elif world.options.magicant_mode == 2:
rom.write_bytes(0x2EC1D8, bytearray([0x6A, 0xC2, 0xEE]))
if not world.options.giygas_required:
rom.write_bytes(0x2EC164, bytearray([0xE8, 0xF0, 0xEE]))
rom.write_bytes(0x02EC1E2, bytearray([0x40, 0xC1, 0xEE]))
rom.write_bytes(0x02EC1E2, bytearray([0x40, 0xC1, 0xEE]))
flavor_address = 0x3FAF10
for i in range(4):
rom.copy_bytes(world.flavor_pointer[i], 2, 0x34B110 + (2 * i))
rom.copy_bytes(0x202008, 0x100, 0x34B000)
for i in range(4):
if world.available_flavors[i] not in ["Mint flavor", "Strawberry flavor", "Banana flavor", "Peanut flavor"]:
rom.write_bytes(flavor_address, bytearray(world.flavor_text[i]))
flavor_addr = flavor_address - 0x3F0000
flavor_addr = struct.pack("H", flavor_addr)
rom.write_bytes(world.flavor_pointer[i], flavor_addr)
rom.write_bytes(world.flavor_pointer[i] + 5, bytearray([0xFF]))
flavor_address += len(world.flavor_text[i])
rom.write_bytes(0x202008 + (0x40 * i), bytearray(flavor_data[world.available_flavors[i]]))
else:
rom.copy_bytes(vanilla_flavor_pointers[world.available_flavors[i]][1], 0x40, 0x202008 + (0x40 * i))
rom.copy_bytes(vanilla_flavor_pointers[world.available_flavors[i]][2], 2, world.flavor_pointer[i])
rom.write_bytes(0x048037, bytearray(world.lumine_text))
starting_item_address = 0
starting_psi = 0
starting_char = 0
starting_psi_types = []
starting_character_count = []
starting_inventory_pointers = {
"Ness": 0x99F3,
"Paula": 0x9A53,
"Jeff": 0x9AB4,
"Poo": 0x9B0F
}
starting_inv_amounts = {
"Ness": 0x0B,
"Paula": 0x0A,
"Jeff": 0x08,
"Poo": 0x0C
}
location = world.multiworld.get_location("Poo - Starting Item", world.player)
if world.starting_character == "Poo" and location.item.name in item_id_table and location.item.player == world.player:
starting_inventory_pointers["Poo"] = 0x9B10
starting_inv_amounts["Poo"] = 0x0B
rom.write_bytes(0x16FB66, struct.pack("H", starting_inventory_pointers[world.starting_character]))
rom.write_bytes(0x16FB68, struct.pack("H", starting_inv_amounts[world.starting_character]))
for item in world.multiworld.precollected_items[player]:
if item.name == world.starting_character: # Write the starting character
rom.write_bytes(0x00B672, bytearray([world.options.starting_character.value + 1]))
if world.options.remote_items:
continue
if item.name == "Photograph":
rom.write_bytes(0x17FEA8, bytearray([0x01]))
if item.name == "Poo" and world.multiworld.get_location("Poo - Starting Item", world.player).item.name in special_name_table and item.player == world.player:
world.multiworld.push_precollected(world.multiworld.get_location("Poo - Starting Item", world.player).item)
elif item.name == "Poo" and world.multiworld.get_location("Poo - Starting Item", world.player).item.name in money_item_table:
world.starting_money += money_item_table[world.multiworld.get_location("Poo - Starting Item", world.player).item.name]
# if item.name == "Poo" and world.multiworld.get_location("Poo - Starting Item", world.player).item.name == "Photograph":
# rom.write_bytes(0x17FEA9, bytearray([0x01]))
if item.name in ["Progressive Bat", "Progressive Fry Pan", "Progressive Gun", "Progressive Bracelet",
"Progressive Other"]:
old_item_name = item.name
item.name = world.progressive_item_groups[item.name][world.start_prog_counts[item.name]]
if world.start_prog_counts[old_item_name] != len(world.progressive_item_groups[old_item_name]) - 1:
world.start_prog_counts[old_item_name] += 1
if item.name in item_id_table:
rom.write_bytes(0x375000 + starting_item_address, bytearray([item_id_table[item.name]]))
starting_item_address += 1
elif item.name in psi_item_table:
if item.name != "Progressive Poo PSI":
if item.name not in starting_psi_types:
rom.write_bytes(0x17FC7C + starting_psi, bytearray([starting_psi_table[item.name]]))
starting_psi_types.append(item.name)
starting_psi += 1
else:
if starting_psi_types.count(item.name) < 2:
rom.write_bytes(0x17FC7C + starting_psi, bytearray([starting_psi_table[item.name]]))
starting_psi_types.append(item.name)
starting_psi += 1
elif item.name in character_item_table and item.name != "Photograph":
if item.name not in starting_character_count:
rom.write_bytes(0x17FC8D + starting_char, bytearray([party_id_nums[item.name]]))
starting_character_count.append(item.name)
starting_char += 1
elif item.name in money_item_table:
world.starting_money += money_item_table[item]
if world.options.random_battle_backgrounds:
bpp2_bgs = [bg_id for bg_id, bpp in battle_bg_bpp.items() if bpp == 2]
bpp4_bgs = [bg_id for bg_id, bpp in battle_bg_bpp.items() if bpp == 4]
for i in range(483):
world.flipped_bg = world.random.randint(0, 100)
if i == 480:
drawn_background = struct.pack("H", 0x00E3)
else:
drawn_background = struct.pack("H", world.random.randint(0x01, 0x0146))
if battle_bg_bpp[struct.unpack("H", drawn_background)[0]] == 4:
drawn_background_2 = struct.pack("H", 0x0000)
else:
drawn_background_2 = struct.pack("H", world.random.choice(bpp2_bgs))
#print(f"ello mate we are doing background {i} at {hex(0xCBD89A + (i * 4))}, the background is {drawn_background[0]}.")
if world.flipped_bg > 33 or drawn_background not in bpp2_bgs:
rom.write_bytes(0x0BD89A + (i * 4), drawn_background)
rom.write_bytes(0x0BD89C + (i * 4), drawn_background_2)
else:
rom.write_bytes(0x0BD89A + (i * 4), drawn_background_2)
rom.write_bytes(0x0BD89C + (i * 4), drawn_background)
rom.write_bytes(0x00B5F1, struct.pack("H", world.random.choice(bpp4_bgs)))
if world.options.random_swirl_colors:
if world.random.random() < 0.5:
rom.write_bytes(0x02E98A, bytearray([0x7F])) # Color math mode
rom.write_bytes(0x02E996, bytearray([0x3F]))
rom.write_bytes(0x300240, bytearray([world.random.randint(0x00, 0x1F)])) # Normal swirls
rom.write_bytes(0x300245, bytearray([world.random.randint(0x00, 0x1F)]))
rom.write_bytes(0x30024A, bytearray([world.random.randint(0x00, 0x1F)]))
rom.write_bytes(0x300253, bytearray([world.random.randint(0x00, 0x1F)])) # Green swirls
rom.write_bytes(0x300258, bytearray([world.random.randint(0x00, 0x1F)]))
rom.write_bytes(0x30025D, bytearray([world.random.randint(0x00, 0x1F)]))
rom.write_bytes(0x300269, bytearray([world.random.randint(0x00, 0x1F)])) # Red swirls
rom.write_bytes(0x30026E, bytearray([world.random.randint(0x00, 0x1F)]))
rom.write_bytes(0x300273, bytearray([world.random.randint(0x00, 0x1F)]))
if not world.options.prefixed_items:
rom.write_bytes(0x15F9DC, bytearray([0x06]))
rom.write_bytes(0x15F9DE, bytearray([0x08]))
rom.write_bytes(0x15F9E0, bytearray([0x05]))
rom.write_bytes(0x15F9E2, bytearray([0x0B]))
rom.write_bytes(0x15F9E4, bytearray([0x0F]))
rom.write_bytes(0x15F9E6, bytearray([0x10]))
# change if necessary
if world.options.psi_shuffle:
write_psi(world, rom)
world.description_pointer = 0x1000
if world.options.armorizer:
randomize_armor(world, rom)
if world.options.weaponizer:
randomize_weapons(world, rom)
music_randomizer(world, rom)
if world.options.map_palette_shuffle:
map_palette_shuffle(world, rom)
if world.options.randomize_psi_palettes:
randomize_psi_palettes(world, rom)
if world.options.randomize_enemy_attributes:
randomize_enemy_attributes(world, rom)
if world.options.randomize_enemy_stats:
randomize_enemy_stats(world, rom)
if world.options.randomize_enemy_attacks:
randomize_enemy_attacks(world, rom)
apply_enemy_shuffle(world, rom)
# randomize_food(world,rom)
write_bosses(world, rom)
if world.options.dungeon_shuffle:
write_dungeon_entrances(world, rom)
world.get_all_spheres.wait()
calculate_scaling(world)
if world.options.shop_randomizer:
write_shop_checks(world, rom, shop_checks)
scale_enemies(world, rom)
world.badge_name = badge_names[world.franklin_protection]
world.badge_name = text_encoder(world.badge_name, 23)
world.badge_name.extend([0x00])
world.starting_money = min(world.starting_money, 99999)
world.starting_money = struct.pack('<I', world.starting_money)
rom.write_bytes(0x17FCD0, world.starting_money)
rom.write_bytes(0x17FCE0, world.prayer_player)
rom.write_bytes(0x17FD00, world.credits_player)
rom.write_bytes(0x155027, world.badge_name)
rom.write_bytes(0x17FD50, struct.pack("H", world.multiworld.players))
rom.write_bytes(0x3FF0A0, world.world_version.encode("ascii"))
display_version = text_encoder(world_version, 15)
display_version.extend([0x02])
rom.write_bytes(0x3CFFBF, display_version)
for element in world.franklinbadge_elements:
for address in protection_checks[element]:
if element == world.franklin_protection:
rom.write_bytes(address, [0xF0])
else:
rom.write_bytes(address, [0x80])
# THIS WILL CRASH IF ADDRESS IS WRONG.
rom.write_bytes(0x2EC909, struct.pack("I", protection_text[world.franklin_protection][0])) # help text
rom.write_bytes(0x2EC957, struct.pack("I", protection_text[world.franklin_protection][1])) # battle text
from Utils import __version__
rom.name = bytearray(f'MOM2AP{__version__.replace(".", "")[0:3]}_{player}_{world.multiworld.seed:11}\0', "utf8")[:21]
rom.name.extend([0] * (21 - len(rom.name)))
rom.write_bytes(0x00FFC0, rom.name)
rom.write_file("token_patch.bin", rom.get_token_binary())
class EBProcPatch(APProcedurePatch, APTokenMixin):
hash = valid_hashes
game = "EarthBound"
patch_file_ending = ".apeb"
result_file_ending = ".sfc"
name: bytearray
procedure = [
("apply_bsdiff4", ["earthbound_basepatch.bsdiff4"]),
("apply_tokens", ["token_patch.bin"]),
("repoint_vanilla_tables", [])
]
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes()
def write_bytes(self, offset: int, value: typing.Iterable[int]) -> None:
self.write_token(APTokenTypes.WRITE, offset, bytes(value))
def copy_bytes(self, source: int, amount: int, destination: int) -> None:
self.write_token(APTokenTypes.COPY, destination, (amount, source))
class EBPatchExtensions(APPatchExtension):
game = "EarthBound"
@staticmethod
def repoint_vanilla_tables(caller: APProcedurePatch, rom: bytes) -> bytes:
rom = LocalRom(rom)
version_check = rom.read_bytes(0x3FF0A0, 16)
version_check = version_check.split(b'\x00', 1)[0]
version_check_str = version_check.decode("ascii")
client_version = world_version
if client_version != version_check_str and version_check_str != "":
raise Exception(f"Error! Patch generated on EarthBound APWorld version {version_check_str} doesn't match client version {client_version}! " +
f"Please use EarthBound APWorld version {version_check_str} for patching.")
elif version_check_str == "":
raise Exception(f"Error! Patch generated on old EarthBound APWorld version, doesn't match client version {client_version}! " +
f"Please verify you are using the same APWorld as the generator.")
for action_number in range(0x013F):
current_action = rom.read_bytes(0x157B68 + (12 * action_number), 12)
rom.write_bytes(0x3FAFB0 + (12 * action_number), current_action)
for psi_number in range(0x35):
current_action = rom.read_bytes(0x158A50 + (15 * psi_number), 15)
rom.write_bytes(0x350000 + (15 * psi_number), current_action)
psi_text_table = rom.read_bytes(0x158D7A, (25 * 17))
rom.write_bytes(0x3B0500, psi_text_table)
psi_anim_config = rom.read_bytes(0x0CF04D, 0x0198)
rom.write_bytes(0x360000, psi_anim_config)
psi_anim_pointers = rom.read_bytes(0x0CF58F, 0x088)
rom.write_bytes(0x360400, psi_anim_pointers)
psi_anim_palettes = rom.read_bytes(0x0CF47F, 0x0110)
rom.write_bytes(0x360600, psi_anim_palettes)
for psi_number in range(0x32):
psi_anim = rom.read_bytes(0x2F8583 + (0x04 * psi_number), 4)
rom.write_bytes(0x3B0003 + (4 * psi_number), psi_anim)
rom.write_bytes(0x3B0003, bytearray([0x4C]))
# rom.write_bytes(0x3B0002, bytearray([0x45]))
main_font_data = rom.read_bytes(0x210C7A, 96)
main_font_gfx = rom.read_bytes(0x210CDA, 0x0C00)
saturn_font_data = rom.read_bytes(0x201359, 96)
saturn_font_gfx = rom.read_bytes(0x2013B9, 0x0C00)
letter_n = rom.read_bytes(0x21169F, 6)
letter_a = rom.read_bytes(0x2114FF, 6)
letter_e = rom.read_bytes(0x21157F, 6)
saturn_a = rom.read_bytes(0x2017DD, 9)
saturn_n = rom.read_bytes(0x20197E, 8)
saturn_e = rom.read_bytes(0x20185C, 24)
accent_tilde = rom.read_bytes(0x2118A1, 2)
saturn_tilde = rom.read_bytes(0x201F7F, 2)
rom.write_bytes(0x3A0000, main_font_data)
rom.write_bytes(0x3C0000, main_font_gfx)
rom.write_bytes(0x3A0100, saturn_font_data)
rom.write_bytes(0x3C1000, saturn_font_gfx)
rom.write_bytes(0x3C0D25, letter_n) # Setup n
rom.write_bytes(0x3C0D45, letter_a) # Setup a
rom.write_bytes(0x3C0D65, letter_e)
rom.write_bytes(0x3C0D22, accent_tilde)
rom.write_bytes(0x3C1D25, saturn_n) # Setup n
rom.write_bytes(0x3C1D45, saturn_a)
rom.write_bytes(0x3C1D63, saturn_e)
rom.write_bytes(0x3C1D22, saturn_tilde)
# ---------------------------------------
ness_level = rom.read_bytes(0x15F5FB, 1)
paula_level = rom.read_bytes(0x15f60f, 1)
jeff_level = rom.read_bytes(0x15f623, 1)
poo_level = rom.read_bytes(0x15f637, 1)
ness_start_exp = rom.read_bytes(0x158F49 + (ness_level[0] * 4), 4)
paula_start_exp = rom.read_bytes(0x1590D9 + (paula_level[0] * 4), 4)
jeff_start_exp = rom.read_bytes(0x159269 + (jeff_level[0] * 4), 4)
poo_start_exp = rom.read_bytes(0x1593F9 + (poo_level[0] * 4), 4)
rom.write_bytes(0x17FD40, ness_start_exp)
rom.write_bytes(0x17FD44, paula_start_exp)
rom.write_bytes(0x17FD48, jeff_start_exp)
rom.write_bytes(0x17FD4C, poo_start_exp)
return rom.get_bytes()
def get_base_rom_bytes(file_name: str = "") -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
file_name = get_base_rom_path(file_name)
base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb")))
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if basemd5.hexdigest() not in valid_hashes:
raise Exception('Supplied Base Rom does not match known MD5 for US(1.0) release. '
'Get the correct game and version, then dump it')
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
return base_rom_bytes
def get_base_rom_path(file_name: str = "") -> str:
options: settings.Settings = settings.get_settings()
if not file_name:
file_name = options["earthbound_options"]["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name
# Fix hint text, I have a special idea where I can give it info on a random region

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