Compare commits

...

93 Commits
0.6.2 ... 0.6.3

Author SHA1 Message Date
Duck
ecb22642af Tests: Handle optional args for get_all_state patch (#5297)
* Make `use_cache` optional

* Pass all kwargs
2025-08-09 00:24:19 +02:00
Exempt-Medic
17ccfdc266 DS3: Don't Create Disabled Locations (#5292) 2025-08-08 15:07:36 -04:00
Scipio Wright
4633f12972 Docs: Use / instead of . for the reference to lttp's options.py (#5300)
* Update options api.md

* o -> O
2025-08-07 20:14:09 +02:00
Silvris
1f6c99635e FF1: fix client breaking other NES games (#5293) 2025-08-05 22:25:11 +02:00
threeandthreee
4e92cac171 LADX: Update Docs (#5290)
* convert ladxr section to markdown, other adjustments
make links clickable
crow icon -> open tracker
adjust for removed sprite sheets
some adjustments in ladxr section for differences in the ap version:
we don't have a casual logic
we don't have stealing options

* fix link, and another correction
2025-08-04 11:46:05 -04:00
Scipio Wright
3b88630b0d TUNIC: Fix zig skip showing up in decoupled + fixed shop #5289 2025-08-04 14:21:58 +02:00
Ishigh1
e6d2d8f455 Core: Added a leading 0 to classification.as_flag #5291 2025-08-04 14:19:51 +02:00
massimilianodelliubaldini
84c2d70d9a Fix regression on 404 redirects 2025-08-03 03:06:42 +00:00
Fabian Dill
d408f7cabc Subnautica: add empty tanks option (#5271) 2025-08-02 21:19:23 +02:00
Fabian Dill
72ae076ce7 WebHost: server render remaining markdown using mistune (#5276)
---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-08-02 21:12:58 +02:00
t3hf1gm3nt
277f21db7a The Legend of Zelda: Stepping Down as Maintainer (#5277) 2025-08-02 13:14:24 -04:00
Fabian Dill
9edd55961f LttP: remove sprite download from setup flow & make sprite repo dynamic (#4830) 2025-08-02 01:26:50 +02:00
Fabian Dill
9ad6959559 LttP: move more stuff out of core (#5049)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-08-01 22:30:30 +02:00
qwint
37a9d94865 Core: Purge Multiworld.option_name (#5050) 2025-08-01 22:06:35 +02:00
Jonathan Tan
e8f5bc1c96 TWW: Fix Death Link (#5270) 2025-08-01 14:39:57 -04:00
Wilhelm Schürmann
8bb236411d Various: Make clients wait a second between connects (#5061) 2025-08-01 14:01:18 -04:00
NewSoupVi
332f955159 The Witness: Comply with new test base structure #5265 2025-08-01 01:16:54 +02:00
Fabian Dill
e7131eddc2 Setup: update cert signing process (#5161) 2025-08-01 00:43:43 +02:00
Fabian Dill
8c07a2c930 WebHost: turn module discovery dynamic (#5218) 2025-08-01 00:43:08 +02:00
black-sliver
2fe51d087f CI: also use new appimage tool in release action 2025-07-31 20:49:30 +00:00
Duck
b1f729a970 Core: Remove Checks for Unsupported Versions (#5067)
* Remove redundant version checks/compatibility

* Change windows7 check

* Edit comments

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-07-31 22:33:56 +02:00
Aaron Wagener
754e0a0de4 Core: hard deprecate per_slot_randoms (#3382)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-07-31 21:42:42 +02:00
josephwhite
7abe7fe304 ALTTP/SNIC/BHC: Stop using Utils.get_settings() (#5239)
* LTTP/SNIC/BHC: Stop using Utils.get_settings()

* SNIClient: use Settings.sni_options
2025-07-31 21:09:00 +02:00
Solidus Snake
8a552e3639 SMZ3: Fix Junk Item Overflow (#5162)
Removed `self.junkItemsNames = [item.Type.name for item in junkItems]` from `create_items` as that was pulling massive amounts of HeartPieces (because they're in junkItems in upstream) to be added if the start_inventory_from_pool was extensive. Getting more than 20 Heart Containers can lead to OHKO situations.

ETank was also removed as a junk item that can be used as filler in the earlier defined list of junk items that AP allows since you should only have 14 in the pool. It's not a problem to have more per se, but you really shouldn't have 27 of them in the pool, either. Ammo and such is much less of a problem to have crazy amounts of.
2025-07-30 07:40:01 -04:00
qwint
743501addc Docs: Remove Settings API Back Compat Section (#5255) 2025-07-29 22:42:55 -04:00
Exempt-Medic
6125e59ce3 Docs: Don't Suggest exclude in create_items (#5256) 2025-07-29 22:33:33 -04:00
Mysteryem
1d8a0b2940 SM: Speed up deepcopy in copy_mixin (#4228) 2025-07-29 22:10:36 -04:00
Nicholas Saylor
2a0ed7faa2 LttP: Remove per_slot_randoms in LttPAdjuster.py (#4898) 2025-07-30 01:18:34 +02:00
BlastSlimey
ad17c7fd21 shapez: Typing Cleanup + Small Docs Rewordings (#5189)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-28 11:01:57 -04:00
Doug Hoskisson
4d17366662 Core: fix dangerous mutable default in Fill (#5247)
discussed here https://discord.com/channels/731205301247803413/731214280439103580/1327712564213448775
2025-07-28 15:41:43 +02:00
Doug Hoskisson
5e2702090c Tests: only get __init__.py tests from test directories (#5135) 2025-07-28 03:10:06 +02:00
JKLeckr
f8d1e4edf3 Ignore .github dir in package test (#5098)
Added comments for ignore code
2025-07-28 02:57:19 +02:00
NewSoupVi
04a3f78605 Core: Support inequality operators ("less than") for Choice option string comparisons (#3769)
* add some unit tests to it

* fix

* Update Options.py

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

* Update Options.py

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-07-27 23:29:21 +02:00
Yussur Mustafa Oraji
ea1e074083 V6: Allow Secret Lab Music to be Randomized (#4643) 2025-07-27 10:45:48 -04:00
Widowmaker-61
199a6df65e Muse Dash: Adding Option to select Goal Song (#4820)
Co-authored-by: Justus Lind <DeamonHunter@users.noreply.github.com>
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-07-27 07:25:59 +02:00
Doug Hoskisson
c9ebf69e0d Core: MultiData typing (#5071) 2025-07-27 01:27:29 +02:00
Duck
a36e6259f1 Core: Add restricted_dumps helper (#5117)
* Add pickling helper that check unpicklability

* Add test condition and generation error handling

* Fix incorrect call and make imports consistent

* Fix newline padding

* Change PicklingError to directly caused by UnpicklingError

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

* Revert to `pickle.dumps` for decompressed multidata

* Fix import order

* Restore pickle import in main

* Re-add for multidata in Main

* Remove multisave checks

* Update MultiServer.py

* Update customserver.py

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-07-26 23:01:40 +02:00
Exempt-Medic
de4014f02c Core/Tests: No Locality Changes After generate_early (#4481)
* Change timing of locality option locking

* Update world api.md

* Remove whitespace
2025-07-26 16:30:55 -04:00
Alchav
774457b362 LTTP: Add Missing Crystal Switch Logic (#4638) 2025-07-26 22:25:06 +02:00
threeandthreee
7a8048a8fd LADX: generate without rom (#4278) 2025-07-26 22:16:00 +02:00
qwint
fa49fef695 Core: use patch extension register directly (#4375)
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-07-26 22:13:15 +02:00
threeandthreee
faac2540bf LADX: fix marin text splitting #5225 2025-07-26 19:32:59 +02:00
NewSoupVi
4e1eb78163 MultiServer: Fix LocationScouts with "only_new" broadcasting hints for found locations over and over (#4482)
* Hints PR number 42069

* Make it explicit

* clarify

* oops

* Port the change to CreateHints
2025-07-26 19:30:38 +02:00
Adrian Priestley
46829487d6 fix(docker): Correct copy command to use recursive flag for EnemizerCLI (#5211)
* fix(docker): Correct copy command to use recursive flag for EnemizerCLI
- Changed 'cp' to 'cp -r' to properly copy EnemizerCLI directory

* docs(deployment): Update container deployment documentation
- Specify minimum versions for Docker and Podman
- Add requirement for Docker Buildx plugin
2025-07-26 14:48:39 +00:00
Mysteryem
8fd021e757 Core: Speed up CollectionState sweeping (#3812)
* Sweep events per-player to reduce sweep iterations

By finding all accessible locations per player and then collecting the
items from those locations, if any collected items belong to a different
player, then that player may be able to access more locations the next
time all of their accessible locations are found. This reduces the
number of iterations necessary to sweep through and collect from all
accessible locations.

* Also sweep per-player in MultiWorld.can_beat_game

* Deduplicate code by using sweep_for_events in can_beat_game

sweep_for_events has been modified to be able to return a generator and
to be able to change the set of locations that are filtered out. This
way, the same code can be used by both functions.

* Skip checking locations by assuming each world only logically depends on itself

While this assumption almost always holds true, worlds are allowed to
logically depend on other worlds, so the sweep always double checks at
the end by checking the locations of every world before finishing.

* Fix missed update to CollectionState.collect implementation

Collecting items with prevent_sweep=True (previously event=True) no
longer always returns True, so the return value should now be checked.

* Comment and variable name consistency/clarity

accessible/inaccessible -> reachable/unreachable
final sweep iteration -> extra sweep iteration
maybe_final_sweep -> checking_if_finished

* Apply suggestions from code review

Use Iterator in return type hint instead of Iterable to help indicate that the returned value can only be iterated once.

Be consistent in return statements. Because sweep_for_events can return a value now, the conditional branch that has no intended return value should explicitly return None.

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

* Update terminology from 'event' to 'advancement'

* Add typing overloads for sweep_for_advancements

This makes it so type-checkers and IDEs can see which calls return
`None` and which calls return `Iterator` so that it doesn't complain
about returning an `Iterator` from `sweep_for_events` or about iterating
through `None` in `can_beat_game`.

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

* Update comment for why discard the player after finding their locations

A lack of clarity was brought up in review.

* Update for removed typing import

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-07-26 14:59:35 +02:00
Fabian Dill
a3af953683 WebHost: list unrecognized games as Other in stats (#5236) 2025-07-26 14:12:45 +02:00
Bryce Wilson
f27da5cc78 BizHawkClient: Add command to pass server messages to emulator (#3039) 2025-07-26 12:42:55 +02:00
qwint
23f0b720de CommonClient: update commands to function without local apworld (#3045) 2025-07-26 04:18:36 +02:00
Duck
f66d8e9a61 WebHost: Update some typing in WebHostLib (#5069) 2025-07-26 01:23:39 +02:00
Exempt-Medic
8499c2fd24 Options: Add PlandoItems to Item&Loc Option Group (#5201) 2025-07-25 15:10:31 -04:00
mobby45
ea4c4dcc0c The Wind Waker: Adding French Translation for Guides (#5174)
* Add files via upload

* Delete fr_The Wind Waker.md

* Delete setup_fr.md

* Add files via upload

* Update fr_The Wind Waker.md

* Update fr_The Wind Waker.md

* Update fr_The Wind Waker.md

* Update worlds/tww/docs/fr_The Wind Waker.md

I agree with that okat!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Agreed

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

agreed!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

agreed!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

agreed!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

forgot to remove that, ok

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

agreed!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Okay!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Agreed

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

agreed

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

agreed

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update fr_The Wind Waker.md

* Update worlds/tww/docs/fr_The Wind Waker.md

okay!

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update setup_fr.md

* Update setup_fr.md

* Update fr_The Wind Waker.md

Modifying lines

* Update setup_fr.md

Character Limits Update

* Update worlds/tww/docs/setup_fr.md

ok

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update worlds/tww/docs/fr_The Wind Waker.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update en_The Wind Waker.md

* Update worlds/tww/docs/setup_fr.md

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update __init__.py

* Update __init__.py

* Update worlds/tww/__init__.py

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>

* Update __init__.py

---------

Co-authored-by: Jonathan Tan <tanjo3@users.noreply.github.com>
2025-07-25 21:08:51 +02:00
BadMagic100
88e8e2408b GER: Move EntranceLookup onto ERPlacementState. Improve usefulness of on_connect. (#4904)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-25 20:55:22 +02:00
Justus Lind
e5815ae5a2 Muse Dash: Update to Rhythm Master Collab (#5235)
* Rhythm Master Collab

* Deprioritze Music Sheets.

* Oops missed this definition.
2025-07-25 20:47:59 +02:00
black-sliver
387f79ceae setup: Downgrade bundled SNI to 0.0.100 (#5228) 2025-07-25 09:15:34 +02:00
black-sliver
bae1259aba CI: switch to new appimagetool (#5233)
Since this does not have versions anymore, we check the sha256
and require manual intervention if it changed.

TODO: look for a way to do reproducible appimages again.
2025-07-25 09:06:22 +02:00
Adrian Priestley
4ac1d91c16 chore(ci): exclude deployment and Docker files from unit test workflow triggers (#5214)
* chore(ci): exclude deployment and Docker files from unit test workflow triggers
- Modify unittests workflow to ignore changes in deploy directory and Docker-related files
2025-07-24 08:35:13 +02:00
Fabian Dill
81b8f3fc0e Factorio: fix rename of mod file leading to incompatibility with base game (#5219) 2025-07-24 00:01:27 +02:00
MarioManTAW
8541c87c97 Paint: Implement New Game (#4955)
* 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

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-07-23 23:27:50 +02:00
NewSoupVi
0e4314ad1e MultiServer: CreateHints command (Allows clients to hint own items in other worlds) (#4317)
* CreateHint command

* Docs

* oops

* forgot an arg

* Update MultiServer.py

* Add documentation on what happens when the hint already exists but with a different status (nothing)

* Early exit if no locations provided

* Add a clarifying comment to the code as well

* change wording a bit
2025-07-23 13:04:07 +02:00
Flore
6b44f217a3 DS3: Edit the setup docs to be more clear (#4618)
* UPDATE: Dark Souls 3 setup docs to be more clear

* UPDATE: DS3 Setup docs to make offline mode more explicit

* UPDATE: Dark Souls 3 setup docs to be more clear

* UPDATE: DS3 Setup docs to make offline mode more explicit

* EDIT: DS3 setup docs to be up to date
2025-07-22 23:39:07 -04:00
Mysteryem
76760e1bf3 OoT: Fix remove not invalidating cached reachability (#5222)
Collecting an item into a CollectionState without sweeping, finding all
reachable locations, removing that item from the state, and then finding
all reachable locations again could result in more locations being
reachable than before the item was initially collected into the
CollectionState.

This issue was present because OoT was not invalidating its reachable
region caches for the different ages when items were removed from the
CollectionState.

To fix the issue, this PR has updated `OOTWorld.remove()` to invalid its
caches, like how `CollectionState.remove()` invalidates the core
Archipelago caches.
2025-07-23 04:01:47 +02:00
Exempt-Medic
d313a74266 ALttP: Fix pre_fill State Sweeping Too Early (#5215) 2025-07-21 14:53:34 -04:00
Adrian Priestley
a535ca31a8 Dockerfile/Core: Prevent module update during container runtime (#5205)
* fix(env): Prevent module update during requirements processing
- Add environment variable SKIP_REQUIREMENTS_UPDATE check
- Ensure update is skipped if SKIP_REQUIREMENTS_UPDATE is set to true

* squash! fix(env): Prevent module update during requirements processing - Add environment variable SKIP_REQUIREMENTS_UPDATE check - Ensure update is skipped if SKIP_REQUIREMENTS_UPDATE is set to true
2025-07-19 15:48:30 +02:00
Exempt-Medic
da0bb80fb4 Raft: Fix filler_item_types TypeError introduced in #4782 (#5203) 2025-07-18 07:28:05 -04:00
David Carroll
fb9026d12d SMZ3: Add Yaml Options to Slot Data (#5111) 2025-07-17 07:48:55 -04:00
NoiseCrush
4ae36ac727 Super Metroid: Improve Option Descriptions and Add Option Groups (#5100) 2025-07-17 07:46:31 -04:00
Adrian Priestley
ffab3a43fc Docker: Add initial configuration for project (#4419)
* feat(docker): Add initial Docker configuration for project
- Add .dockerignore file to ignore unnecessary files
- Create Dockerfile with basic build and deployment configuration

* feat(docker): Updated Docker configuration for improved security and build efficiency
- Removed sensitive files from .dockerignore
- Moved WORKDIR to /app in Dockerfile
- Added gunicorn==23.0.0 dependency in RUN command
- Created new docker-compose.yml file for service definition

* feat(deployment): Implement containerized deployment configuration

- Add additional environment variables for Python optimization
- Update Dockerfile with new dependencies: eventlet, gevent, tornado
- Create docker-compose.yml and configure services for web and nginx
- Implement example configurations for web host settings and gunicorn
- Establish nginx configuration for reverse proxy
- Remove outdated docker-compose.yml from root directory

* feat(deploy): Introduce Docker Compose configuration for multi-world deployment
- Separate web service into two containers, one for main process and one for gunicorn
- Update container configurations for improved security and maintainability
- Remove unused volumes and network configurations

* docs: Add new documentation for deploying Archipelago using containers
- Document standalone image build and run process
- Include example Docker Compose file for container orchestration
- Provide information on services defined in the `docker-compose.yaml` file
- Mention optional Enemizer feature and Git requirements

* fixup! feat(docker): Updated Docker configuration for improved security and build efficiency - Removed sensitive files from .dockerignore - Moved WORKDIR to /app in Dockerfile - Added gunicorn==23.0.0 dependency in RUN command - Created new docker-compose.yml file for service definition

* feat(deploy): Updated gunicorn configuration example
- Adjusted worker and thread counts
- Switched worker class from sync to gthread
- Changed log level to info
- Added example code snippet for customizing worker count

* fix(deploy): Adjust concurrency settings for self-launch configuration
- Reduce the number of world generators from 8 to 3
- Decrease the number of hosters from 5 to 4

* docs(deploy using containers): Improve readability, fix broken links
- Update links to other documentation pages
- Improve formatting for better readability
- Remove unnecessary sections and files
- Add note about building the image requiring a local copy of ArchipelagoMW source code

* Update deploy/example_config.yaml

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

* Update deploy/example_selflaunch.yaml

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

* Update Dockerfile

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

* Update deploy/example_selflaunch.yaml

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

* fixup! Update Dockerfile

* fix(Dockerfile): Update package installations to use latest versions
- Remove specific version pins for git and libc6-dev
- Ensure compatibility with newer package updates

* feat(ci): Add GitHub Actions workflow for building and publishing Docker images
- Create a new workflow for Docker image build and publish
- Configure triggers for push and pull_request on main branch
- Set up QEMU and Docker Buildx for multi-platform builds
- Implement Docker login for GitHub Container Registry
- Include Docker image metadata extraction and tagging

* feat(healthcheck): Update Dockerfile and docker-compose for health checks
- Add health check for the Webhost service in Dockerfile
- Modify docker-compose to include a placeholder health check for multiworld service
- Standardize comments and remove unnecessary lines

* Revert "feat(ci): Add GitHub Actions workflow for building and publishing Docker images"

This reverts commit 32a51b2726.

* feat(docker): Enhance Dockerfile with Cython build stage
- Add Cython builder stage for compiling speedups
- Update package installation and organization for efficiency
- Improve caching by copying requirements before installing
- Add documentation for rootless Podman

* fixup! feat(docker): Enhance Dockerfile with Cython build stage - Add Cython builder stage for compiling speedups - Update package installation and organization for efficiency - Improve caching by copying requirements before installing - Add documentation for rootless Podman

---------

Co-authored-by: Adrian Priestley <apriestley@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Adrian Priestley <apriestley@bob.localdomain>
2025-07-17 10:00:57 +02:00
Star Rauchenberger
e38d04c655 Lingo: Fix Painting Gen Failures on Panels Mode Door Shuffle (#5199)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-16 23:49:01 -04:00
Aaron Wagener
1923d6b1bc Options: Assert Not All Option in Options.as_dict (#5039)
* Options: forbid worlds just dumping every single option they don't need

* make the equal proper

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-16 12:57:11 -04:00
CookieCat
608a38f873 AHIT: Fix Test Fail for assert_not_all_options (#5197) 2025-07-16 12:19:35 -04:00
Jérémie Bolduc
604ab79af9 Stardew Valley: Add walnutsanity prefix to locations (#4934) 2025-07-16 17:57:06 +02:00
qwint
4a43a6ae13 Docs: Clean up SUUID Post #4944 (#5196) 2025-07-16 11:51:34 -04:00
qwint
e9e0861eb7 WebHostLib: Properly Format IDs in API Responses (#4944)
* update the id formatter to use staticmethods to not fake the unused self arg, and then use the formatter for the user session endpoints

* missed an id (ty treble)

* clean up duplicate code

* Update WebHostLib/__init__.py

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

* keep the BaseConverter format

* lol, change all the instances

* revert this

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-07-16 11:34:28 -04:00
Jacob Lewis
477028a025 Dics: Add Webhost API Documententation (#4887)
* capitialization changes

* ditto

* Revert "ditto"

This reverts commit 17cf596735.

* Revert "capitialization changes"

This reverts commit 6fb86c6568.

* full revert and full commit

* Update docs/webhost api.md

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

* Update docs/webhost api.md

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

* Update docs/webhost api.md

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

* Update webhost api.md

* Removed in-devolopment API

* Apply standard capitilization and grammar flow

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

* declarative language

* Apply suggestions from code review

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

* datapackage_checksum clarification, and /datapackage clairfication

* /dp/checksum clarification

* Detailed responces and /generation breakdown

* Update webhost api.md

* Made output anonomous

* Update docs/webhost api.md

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

* Swapped IDs to UUID, and added language around UUID vs SUUID

* Apply suggestions from code review

formatting and grammar

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

* Condensed paragraphs and waterfalled headders

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-16 11:11:07 -04:00
NewSoupVi
b90dcfb041 The Witness: Add Glass Factory Entry Panel as a location in all options #4695 2025-07-16 10:31:12 +02:00
Scipio Wright
1790a389c7 TUNIC: Update Tests Per #4982 (#5191) 2025-07-15 17:04:27 -04:00
Aaron Wagener
deed9de3e7 Core: Don't Cache the get_all_state Result (#4795)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-15 16:40:58 -04:00
SunCat
9e748332dc Various Games: Improve Custom Death Link Option Description (#4171)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com>
2025-07-15 16:01:53 -04:00
GreenMarco
749c2435ed Hollow Knight: Add Spanish Language Docs (#5156)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-07-15 15:43:54 -04:00
Danaël V.
6360609980 Witness: Add French and German Setup Documentation (#2527)
Co-authored-by: Lolo <lowgau@gmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-15 15:43:20 -04:00
qwint
fed60ca61a Hollow Knight: Explicitly Exclude Palace Items as Filler (#5119) 2025-07-15 15:09:56 -04:00
Fabian Dill
f18f9e2dce Core: increment version (#5194) 2025-07-15 21:04:06 +02:00
Eindall
e1b26bc76f Stardew Valley: Add French Guide (#4697)
Co-authored-by: tmarquis <thomas.marquis@cellance.com>
2025-07-15 15:02:17 -04:00
NewSoupVi
2aada8f683 Core: Add new ItemClassification "deprioritized" which will not be placed on priority locations (if possible) (#4610)
* Add new deprioritized item flag

* 4 retries

* indent

* .

* style

* I think this is nicer

* Nicer

* remove two lines again that I added unnecessarily

* I think this test makes a bit more sense like this

* Idk how to word this lol

* Add progression_deprioritized_skip_balancing bc why not ig

* More text

* Update Fill.py

* Update Fill.py

* I am the big stupid

* Actually collect the other half of progression items into state when filling without them

* More clarity on the descriptions (hopefully)

* visually separate technical description and use cases

* Actually make the call do what the comments say it does
2025-07-15 20:35:27 +02:00
Mysteryem
f9f386fa19 Core: Cache previous swap states to use as the base state to sweep from (#3859)
The previous swap_state can often be used as the base state to create
the next swap_state. This previous swap_state will already have
collected all items in item_pool and is likely to have checked many
locations, meaning that creating the next swap_state from it instead of
from base_state is faster.

From generating with extra code to raise an exception if more than 2
previous swap states were used, and using A Hat in Time and Pokemon
Red/Blue yamls that often result in lots of swapping in progression
fill, I could not get a single seed go through more than 2 previous swap
states. A few worlds' pre-fills do often use more than 2 previous swap
states, notably LADX which sometimes goes through over 20.

Given a 20 player Pokemon Red/Blue multiworld that usually generates in
around 16 or 17 seconds, but on a specific seed that results in 56
swaps, generation went from about 260 seconds before this patch to about
104 seconds after this patch (generated with a meta.yaml to disable
progression balancing and `python -O Generate.py --skip_output`).

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-07-15 20:33:24 +02:00
Mysteryem
507a9a53ef Core: Cleanup: Replace direct calling of dunder methods on objects (#4584)
Calling the dunder method has to:
1. Look up the dunder method for that object/class
2. Bind a new method instance to the object instance
3. Call the method with its arguments
4. Run the appropriate operation on the object

Whereas running the appropriate operation on the object from the start
skips straight to step 4.

Region.Register.__getitem__ is called a lot without #4583. In that case,
generation of 10 template Blasphemous yamls with
`--skip_output --seed 1` and progression balancing disabled went from
19.0s to 18.8s (1.3% reduction in generation duration).

From profiling with `timeit`
```py
        def __getitem__(self, index: int) -> Location:
            return self._list[index]
```
appears to be about twice as fast as the old code:
```py
        def __getitem__(self, index: int) -> Location:
            return self._list.__getitem__(index)
```

Besides this, there is not expected to be any noticeable difference in
performance, and there is not expected to be any difference in semantics
with these changes.

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-07-15 20:33:11 +02:00
NewSoupVi
c1ae637fa7 Core: Crash on full accessibility if there are unreachable locations (Yes, you read that right) #3787 2025-07-15 20:32:53 +02:00
NewSoupVi
f967444ac2 Core: Assert that all the items in the multiworld itempool are actually unplaced at the start of distribute_items_restrictive (#5109)
* Assert at the beginning of distribute items restrictive that no items in the itempool already have locations associated with them

* actual message

* placement

* oops

* Update Fill.py
2025-07-15 20:32:22 +02:00
qwint
c879307b8e CC: Add Assert to Catch Old Datapackage Lookup API (#5131) 2025-07-15 14:30:13 -04:00
qwint
c8ca3e643d Core: Adds Visual Formatting to Option Group Headers in Template Yamls (#5092)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-15 14:19:50 -04:00
lordlou
9a648efa70 Super Metroid: Only Put Relevant Options in slot_data (#5192)
* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* reduced slot_data to only what should be needed by PopTracker (for https://github.com/ArchipelagoMW/Archipelago/pull/5039)
2025-07-15 11:48:28 -04:00
qwint
f45410c917 Core: Update UUID handling to be more easily sharable between libraries (#5088)
moves uuid caching to appdata and uuid generation to be a random uuid instead of getnode's hardware address driven identifier and updates docs to point to the shared cache
2025-07-15 07:10:40 +02:00
black-sliver
ec3f168a09 Doc: match statement in style guide (#5187)
* Test: add micro benchmark for match

* Doc: add 'match' to python style guide
2025-07-14 07:22:10 +00:00
182 changed files with 4508 additions and 1805 deletions

210
.dockerignore Normal file
View File

@@ -0,0 +1,210 @@
.git
.github
.run
docs
test
typings
*Client.py
.idea
.vscode
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc
*.gba
*.wixobj
*.lck
*.db3
*multidata
*multisave
*.archipelago
*.apsave
*.BIN
*.puml
setups
build
bundle/components.wxs
dist
/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/sni-*/
/appimagetool*
/host.yaml
/options.yaml
/config.yaml
/logs/
_persistent_storage.yaml
mystery_result_*.yaml
*-errors.txt
success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
installer.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# vim editor
*.swp
# SageMath parsed files
*.sage.py
# Environments
.env
.venv*
env/
venv/
/venv*/
ENV/
env.bak/
venv.bak/
*.code-workspace
shell.nix
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version
#undertale stuff
/Undertale/
# OS General Files
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
[Dd]esktop.ini

View File

@@ -19,7 +19,12 @@ on:
env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation
id-token: 'write'
@@ -134,10 +139,13 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |

View File

@@ -9,7 +9,12 @@ on:
env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
permissions: # permissions required for attestation
id-token: 'write'
@@ -122,10 +127,13 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |

View File

@@ -8,18 +8,24 @@ on:
paths:
- '**'
- '!docs/**'
- '!deploy/**'
- '!setup.py'
- '!Dockerfile'
- '!*.iss'
- '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
pull_request:
paths:
- '**'
- '!docs/**'
- '!deploy/**'
- '!setup.py'
- '!Dockerfile'
- '!*.iss'
- '!.gitignore'
- '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'

View File

@@ -407,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue
except CancelledError:
pass

View File

@@ -5,12 +5,13 @@ import functools
import logging
import random
import secrets
import warnings
from argparse import Namespace
from collections import Counter, deque
from collections import Counter, deque, defaultdict
from collections.abc import Collection, MutableSequence
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)
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -153,17 +154,11 @@ class MultiWorld():
self.algorithm = 'balanced'
self.groups = {}
self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
@@ -182,7 +177,7 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
"world's random object instead (usually self.random)")
"world's random object instead (usually self.random)", True)
self.plando_options = PlandoOptions.none
def get_all_ids(self) -> Tuple[int, ...]:
@@ -227,17 +222,8 @@ class MultiWorld():
self.seed_name = name if name else str(self.seed)
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.", True)
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
@@ -438,12 +424,27 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
"""
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
it is able to reach, building as complete of a completed game state as possible.
:param use_cache: Deprecated and unused.
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
sweeping, such as before entrance randomization is complete.
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
state.
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
items it can.
:return: The completed CollectionState.
"""
if __debug__ and use_cache is not None:
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
DeprecationWarning)
ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool:
@@ -456,8 +457,6 @@ class MultiWorld():
if perform_sweep:
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
return ret
def get_items(self) -> List[Item]:
@@ -571,26 +570,9 @@ class MultiWorld():
if self.has_beaten_game(state):
return True
base_locations = self.get_locations() if locations is None else locations
prog_locations = {location for location in base_locations if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
state.collect(location.item, True, location)
prog_locations -= sphere
for _ in state.sweep_for_advancements(locations,
yield_each_sweep=True,
checked_locations=state.locations_checked):
if self.has_beaten_game(state):
return True
@@ -706,6 +688,12 @@ class MultiWorld():
sphere.append(locations.pop(n))
if not sphere:
if __debug__:
from Fill import FillError
raise FillError(
f"Could not access required locations for accessibility check. Missing: {locations}",
multiworld=self,
)
# ran out of places and did not finish yet, quit
logging.warning(f"Could not access required locations for accessibility check."
f" Missing: {locations}")
@@ -869,20 +857,133 @@ class CollectionState():
"Please switch over to sweep_for_advancements.")
return self.sweep_for_advancements(locations)
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None:
locations = self.multiworld.get_filled_locations()
reachable_advancements = True
# since the loop has a good chance to run more than once, only filter the advancements once
locations = {location for location in locations if location.advancement and location not in self.advancements}
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
yield_each_sweep: bool) -> Iterator[None]:
"""
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
of a yield statement.
"""
all_players = {player for player, _ in advancements_per_player}
players_to_check = all_players
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
# sweep is finished.
checking_if_finished = False
while players_to_check:
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
next_players_to_check = set()
while reachable_advancements:
reachable_advancements = {location for location in locations if location.can_reach(self)}
locations -= reachable_advancements
for advancement in reachable_advancements:
self.advancements.add(advancement)
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
self.collect(advancement.item, True, advancement)
for player, locations in advancements_per_player:
if player not in players_to_check:
next_advancements_per_player.append((player, locations))
continue
# Accessibility of each location is checked first because a player's region accessibility cache becomes
# stale whenever one of their own items is collected into the state.
reachable_locations: List[Location] = []
unreachable_locations: List[Location] = []
for location in locations:
if location.can_reach(self):
# Locations containing items that do not belong to `player` could be collected immediately
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
# items at reachable locations are collected in a single loop.
reachable_locations.append(location)
else:
unreachable_locations.append(location)
if unreachable_locations:
next_advancements_per_player.append((player, unreachable_locations))
# A previous player's locations processed in the current `while players_to_check` iteration could have
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
# their items is collected.
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
# more performant to instead discard `player` from `next_players_to_check` once their locations have
# been processed.
next_players_to_check.discard(player)
# Collect the items from the reachable locations.
for advancement in reachable_locations:
self.advancements.add(advancement)
item = advancement.item
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
if self.collect(item, True, advancement):
# The player the item belongs to may be able to reach additional locations in the next sweep
# iteration.
next_players_to_check.add(item.player)
if not next_players_to_check:
if not checking_if_finished:
# It is assumed that each player's world only logically depends on itself, which may not be the
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
checking_if_finished = True
next_players_to_check = all_players
else:
checking_if_finished = False
players_to_check = next_players_to_check
advancements_per_player = next_advancements_per_player
if yield_each_sweep:
yield
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
yield_each_sweep: Literal[True],
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
@overload
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
yield_each_sweep: Literal[False] = False,
checked_locations: Optional[Set[Location]] = None) -> None: ...
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
"""
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
until there are no more reachable locations that contain uncollected advancement items.
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
self.advancements when None.
"""
if checked_locations is None:
checked_locations = self.advancements
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
advancements_per_player: List[Tuple[int, List[Location]]]
if locations is None:
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
advancements_per_player = []
for player, locations_dict in self.multiworld.regions.location_cache.items():
filtered_locations = [location for location in locations_dict.values()
if location.advancement and location not in checked_locations]
if filtered_locations:
advancements_per_player.append((player, filtered_locations))
else:
# Filter and separate the locations into a list for each player.
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
for location in locations:
if location.advancement and location not in checked_locations:
advancements_per_player_dict[location.player].append(location)
# Convert to a list of tuples.
advancements_per_player = list(advancements_per_player_dict.items())
del advancements_per_player_dict
if yield_each_sweep:
# Return a generator that will yield at the end of each sweep iteration.
return self._sweep_for_advancements_impl(advancements_per_player, True)
else:
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
# once started, then start and exhaust the generator by attempting to iterate it.
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
assert False, "Generator yielded when it should have run to completion without yielding"
return None
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
@@ -1150,13 +1251,13 @@ class Region:
self.region_manager = region_manager
def __getitem__(self, index: int) -> Location:
return self._list.__getitem__(index)
return self._list[index]
def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError()
def __len__(self) -> int:
return self._list.__len__()
return len(self._list)
def __iter__(self):
return iter(self._list)
@@ -1170,8 +1271,8 @@ class Region:
class LocationRegister(Register):
def __delitem__(self, index: int) -> None:
location: Location = self._list.__getitem__(index)
self._list.__delitem__(index)
location: Location = self._list[index]
del self._list[index]
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
@@ -1182,8 +1283,8 @@ class Region:
class EntranceRegister(Register):
def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list.__getitem__(index)
self._list.__delitem__(index)
entrance: Entrance = self._list[index]
del self._list[index]
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
@@ -1430,31 +1531,47 @@ class Location:
class ItemClassification(IntFlag):
filler = 0b0000
filler = 0b00000
""" aka trash, as in filler items like ammo, currency etc """
progression = 0b0001
progression = 0b00001
""" Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """
useful = 0b0010
useful = 0b00010
""" Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b0100
trap = 0b00100
""" Item that is detrimental in some way. """
skip_balancing = 0b1000
skip_balancing = 0b01000
""" should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
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) """
progression_skip_balancing = 0b1001 # only progression gets balanced
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.
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",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b0111)
return int(self & 0b00111)
class Item:
@@ -1498,6 +1615,10 @@ class Item:
def trap(self) -> bool:
return ItemClassification.trap in self.classification
@property
def deprioritized(self) -> bool:
return ItemClassification.deprioritized in self.classification
@property
def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap)
@@ -1805,7 +1926,7 @@ class Tutorial(NamedTuple):
description: str
language: str
file_name: str
link: str
link: str # unused
authors: List[str]

View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
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
@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True
def get_current_datapackage(self) -> dict[str, typing.Any]:
"""
Return datapackage for current game if known.
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
"""
if not self.ctx.game:
return {}
checksum = self.ctx.checksums[self.ctx.game]
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state.
Can be given text, which will be used as filter."""
@@ -107,7 +118,9 @@ class ClientCommandProcessor(CommandProcessor):
return False
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
lookup = self.get_current_datapackage().get("location_name_to_id", {})
for location, location_id in lookup.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -128,43 +141,91 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_items(self):
def output_datapackage_part(self, key: str, name: str) -> bool:
"""
Helper to digest a specific section of this game's datapackage.
:param key: The dictionary key in the datapackage.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine {name}.")
return False
lookup = self.get_current_datapackage().get(key)
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
self.output(f"{name} for {self.ctx.game}")
for key in lookup:
self.output(key)
return True
def _cmd_items(self) -> bool:
"""List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
return self.output_datapackage_part("item_name_to_id", "Item Names")
def _cmd_item_groups(self):
"""List all item group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing item groups.")
return False
self.output(f"Item Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
self.output(group_name)
def _cmd_locations(self):
def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
return self.output_datapackage_part("location_name_to_id", "Location Names")
def _cmd_location_groups(self):
"""List all location group names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing location groups.")
return False
self.output(f"Location Group Names for {self.ctx.game}")
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
self.output(group_name)
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
filter_key: str,
name: str) -> bool:
"""
Logs an item or location group from the player's game's datapackage.
def _cmd_ready(self):
:param group_key: Either Item or Location group to be processed.
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
:param name: Printed to the user as context for the part.
:return: Whether the process was successful.
"""
if not self.ctx.game:
self.output(f"No game set, cannot determine existing {name} Groups.")
return False
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
.get(self.ctx.game, {}).get(group_key, {})
if lookup is None:
self.output("datapackage not yet loaded, try again")
return False
if filter_key:
if filter_key not in lookup:
self.output(f"Unknown {name} Group {filter_key}")
return False
self.output(f"{name}s for {name} Group \"{filter_key}\"")
for entry in lookup[filter_key]:
self.output(entry)
else:
self.output(f"{name} Groups for {self.ctx.game}")
for group in lookup:
self.output(group)
return True
@mark_raw
def _cmd_item_groups(self, key: str = "") -> bool:
"""
List all item group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("item_name_groups", key, "Item")
@mark_raw
def _cmd_location_groups(self, key: str = "") -> bool:
"""
List all location group names for the currently running game.
:param key: Which item group to filter to. Will log all groups if empty.
"""
return self.output_group_part("location_name_groups", key, "Location")
def _cmd_ready(self) -> bool:
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@@ -174,6 +235,7 @@ class ClientCommandProcessor(CommandProcessor):
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
return True
def default(self, raw: str):
"""The default message parser to be used when parsing any messages that do not match a command"""
@@ -201,6 +263,7 @@ class CommonContext:
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
return self._game_store[key]
def __len__(self) -> int:
@@ -210,7 +273,7 @@ class CommonContext:
return iter(self._game_store)
def __repr__(self) -> str:
return self._game_store.__repr__()
return repr(self._game_store)
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
@@ -378,6 +441,8 @@ class CommonContext:
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
if self.game:
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package)
# execution
@@ -637,6 +702,24 @@ class CommonContext:
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
def consume_network_item_groups(self):
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
def consume_network_location_groups(self):
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
if self.game in current_cache:
current_cache[self.game].update(data)
else:
current_cache[self.game] = data
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
# data storage
def set_notify(self, *keys: str) -> None:
@@ -937,6 +1020,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
if ctx.game:
game = ctx.game
else:
game = ctx.slot_info[ctx.slot][1]
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -1017,11 +1106,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_item_groups()
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
ctx.consume_network_location_groups()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints()
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_item_groups()
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
ctx.consume_network_location_groups()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:

100
Dockerfile Normal file
View File

@@ -0,0 +1,100 @@
# hadolint global ignore=SC1090,SC1091
# Source
FROM scratch AS release
WORKDIR /release
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
# Enemizer
FROM alpine:3.21 AS enemizer
ARG TARGETARCH
WORKDIR /release
COPY --from=release /release/Enemizer.zip .
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
apk add unzip=6.0-r15 --no-cache && \
unzip -u Enemizer.zip -d EnemizerCLI && \
chmod -R 777 EnemizerCLI; \
else touch EnemizerCLI; fi
# Cython builder stage
FROM python:3.12 AS cython-builder
WORKDIR /build
# Copy and install requirements first (better caching)
COPY requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
setuptools
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim AS archipelago
ARG TARGETARCH
ENV VIRTUAL_ENV=/opt/venv
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install requirements
# hadolint ignore=DL3008
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
gcc=4:12.2.0-3 \
libc6-dev \
libtk8.6=8.6.13-2 \
g++=4:12.2.0-3 \
curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create and activate venv
RUN python -m venv $VIRTUAL_ENV; \
. $VIRTUAL_ENV/bin/activate
# Copy and install requirements first (better caching)
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
RUN pip install --no-cache-dir -r \
WebHostLib/requirements.txt \
gunicorn==23.0.0
COPY . .
COPY --from=cython-builder /build/*.so ./
# Run ModuleUpdate
RUN python ModuleUpdate.py -y
# Purge unneeded packages
RUN apt-get purge -y \
git \
gcc \
libc6-dev \
g++ && \
apt-get autoremove -y
# Copy necessary components
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
# No release for arm architecture. Skip.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
cp -r /tmp/EnemizerCLI EnemizerCLI; \
fi; \
rm -rf /tmp/EnemizerCLI
# Define health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT:-80} || exit 1
# Ensure no runtime ModuleUpdate.
ENV SKIP_REQUIREMENTS_UPDATE=true
ENTRYPOINT [ "python", "WebHost.py" ]

91
Fill.py
View File

@@ -116,6 +116,13 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
else:
# we filled all reachable spots.
if swap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length = 3
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe)
for unsafe in (False, True)
@@ -130,9 +137,30 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
for previous_safe_swap_state in previous_safe_swap_state_cache:
# If a state has already checked the location of the swap, then it cannot be used.
if location not in previous_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
if not unsafe:
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
@@ -330,7 +358,12 @@ def fast_fill(multiworld: MultiWorld,
return item_pool[placing:], fill_locations[placing:]
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
def accessibility_corrections(multiworld: MultiWorld,
state: CollectionState,
locations: list[Location],
pool: list[Item] | None = None) -> None:
if pool is None:
pool = []
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
@@ -450,6 +483,12 @@ def distribute_early_items(multiworld: MultiWorld,
def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
assert all(item.location is None for item in multiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
)
fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
# get items to distribute
@@ -492,18 +531,48 @@ def distribute_items_restrictive(multiworld: MultiWorld,
single_player = multiworld.players == 1 and not multiworld.groups
if prioritylocations:
regular_progression = []
deprioritized_progression = []
for item in progitempool:
if item.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations:
if prioritylocations and regular_progression:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
# deprioritized items are still not in the mix, so they need to be collected into state first.
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False, allow_partial=True)
if prioritylocations and deprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
if prioritylocations and deprioritized_progression:
# retry with deprioritized items AND without one_item_per_player optimisation
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 3", one_item_per_player=False)
# restore original order of progitempool
progitempool[:] = [item for item in progitempool if not item.location]
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations

View File

@@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past"
WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
@@ -40,7 +41,6 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
@@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
def _get_help_string(self, action):
return textwrap.dedent(action.help)
# See argparse.BooleanOptionalAction
class BooleanOptionalActionWithDisable(argparse.Action):
def __init__(self,
@@ -364,10 +365,10 @@ def run_sprite_update():
logging.info("Done updating sprites")
def update_sprites(task, on_finish=None):
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
resultmessage = ""
successful = True
sprite_dir = user_path("data", "sprites", "alttpr")
sprite_dir = user_path("data", "sprites", "alttp", "remote")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
@@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None):
on_finish(successful, resultmessage)
try:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
task.update_status("Downloading remote sprites list")
with urlopen(repository_url, context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
@@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None):
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
remote_filenames = [filename for (_, filename) in remote_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e)
@@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None):
successful = False
if successful:
resultmessage = "alttpr sprites updated successfully"
resultmessage = "Remote sprites updated successfully"
task.queue_event(finished)
@@ -868,7 +869,7 @@ class SpriteSelector():
def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir)
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
remote_frametitle = Label(self.window, text='Remote Sprites')
custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -877,8 +878,8 @@ class SpriteSelector():
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(remote_frametitle, self.remote_sprite_dir,
'Remote sprites not found. Click "Update remote sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
@@ -891,11 +892,18 @@ class SpriteSelector():
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
button.pack(side=RIGHT, padx=(5, 0))
repository_label = Label(frame, text='Sprite Repository:')
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
repository_entry = Entry(frame, textvariable=self.repository_url)
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
button.pack(side=LEFT,padx=(0,5))
button.pack(side=LEFT, padx=(0, 5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
@@ -1055,7 +1063,7 @@ class SpriteSelector():
for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_alttpr_sprites(self):
def update_remote_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy()
self.parent.update()
@@ -1068,7 +1076,8 @@ class SpriteSelector():
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
on_finish, self.repository_url.get())
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
@@ -1158,12 +1167,13 @@ class SpriteSelector():
os.makedirs(self.custom_sprite_dir)
@property
def alttpr_sprite_dir(self):
return user_path("data", "sprites", "alttpr")
def remote_sprite_dir(self):
return user_path("data", "sprites", "alttp", "remote")
@property
def custom_sprite_dir(self):
return user_path("data", "sprites", "custom")
return user_path("data", "sprites", "alttp", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:

View File

@@ -286,6 +286,7 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue

49
Main.py
View File

@@ -1,10 +1,11 @@
import collections
from collections.abc import Mapping
import concurrent.futures
import logging
import os
import pickle
import tempfile
import time
from typing import Any
import zipfile
import zlib
@@ -14,7 +15,7 @@ from Fill import FillError, balance_multiworld_progression, distribute_items_res
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from NetUtils import convert_to_base_types
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple
from Utils import __version__, output_path, restricted_dumps, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -93,6 +94,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
del local_early
del early
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
# Clear non-applicable local and non-local items.
if multiworld.players == 1:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
logger.info('Creating MultiWorld.')
AutoWorld.call_all(multiworld, "create_regions")
@@ -100,12 +110,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
AutoWorld.call_all(multiworld, "create_items")
logger.info('Calculating Access Rules.')
for player in multiworld.player_ids:
# items can't be both local and non-local, prefer local
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
AutoWorld.call_all(multiworld, "set_rules")
for player in multiworld.player_ids:
@@ -126,11 +130,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules.
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
if multiworld.players > 1:
locality_rules(multiworld)
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
@@ -174,7 +176,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.link_items()
if any(multiworld.item_links.values()):
if any(world.options.item_links for world in multiworld.worlds.values()):
multiworld._all_state = None
logger.info("Running Item Plando.")
@@ -239,11 +241,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
def write_multidata():
import NetUtils
from NetUtils import HintStatus
slot_data = {}
client_versions = {}
games = {}
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
slot_info = {}
slot_data: dict[int, Mapping[str, Any]] = {}
client_versions: dict[int, tuple[int, int, int]] = {}
games: dict[int, str] = {}
minimum_versions: NetUtils.MinimumVersions = {
"server": AutoWorld.World.required_server_version, "clients": client_versions
}
slot_info: dict[int, NetUtils.NetworkSlot] = {}
names = [[name for player, name in sorted(multiworld.player_name.items())]]
for slot in multiworld.player_ids:
player_world: AutoWorld.World = multiworld.worlds[slot]
@@ -258,7 +262,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in multiworld.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
precollected_hints: dict[int, set[NetUtils.Hint]] = {
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
}
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
@@ -315,7 +321,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = {
multidata: NetUtils.MultiData | bytes = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -325,7 +331,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(version_tuple),
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": multiworld.seed_name,
@@ -333,12 +339,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
"datapackage": data_package,
"race_mode": int(multiworld.is_race),
}
# TODO: change to `"version": version_tuple` after getting better serialization
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(pickle.dumps(multidata), 9)
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format

View File

@@ -16,7 +16,11 @@ elif sys.version_info < (3, 10, 1):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
_skip_update = bool(
getattr(sys, "frozen", False) or
multiprocessing.parent_process() or
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
)
update_ran = _skip_update

View File

@@ -43,7 +43,7 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, Hint, HintStatus
SlotType, LocationStore, MultiData, Hint, HintStatus
from BaseClasses import ItemClassification
@@ -445,7 +445,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
@@ -546,6 +546,7 @@ class Context:
def _save(self, exit_save: bool = False) -> bool:
try:
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
@@ -752,7 +753,7 @@ class Context:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None):
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -767,8 +768,9 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# only remember hints that were not already found at the time of creation
if not hint.found:
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
# For LocationScouts use-cases, all hints should be remembered
if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
@@ -1946,10 +1948,52 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'CreateHints':
location_player = args.get("player", client.slot)
locations = args["locations"]
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
if not locations:
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
hints = []
for location in locations:
if location_player != client.slot and location not in ctx.locations[location_player]:
error_text = (
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
"Please refrain from hinting other slot's locations that you don't know contain your items."
)
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
target_item, item_player, flags = ctx.locations[location_player][location]
if client.slot not in ctx.slot_set(item_player):
if status != HintStatus.HINT_UNSPECIFIED:
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
if client.slot != location_player:
error_text = "CreateHints: Can only create hints for own items or own locations."
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
"text": error_text, "original_cmd": cmd}])
return
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
# As of writing this code, only_new=True does not update status for existing hints
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
ctx.save()
elif cmd == 'UpdateHint':
location = args["location"]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import typing
import enum
import warnings
@@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
group_members: Sequence[int] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
@@ -471,6 +472,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
location_id not in checked])
class MinimumVersions(typing.TypedDict):
server: tuple[int, int, int]
clients: dict[int, tuple[int, int, int]]
class GamesPackage(typing.TypedDict, total=False):
item_name_groups: dict[str, list[str]]
item_name_to_id: dict[str, int]
location_name_groups: dict[str, list[str]]
location_name_to_id: dict[str, int]
checksum: str
class DataPackage(typing.TypedDict):
games: dict[str, GamesPackage]
class MultiData(typing.TypedDict):
slot_data: dict[int, Mapping[str, typing.Any]]
slot_info: dict[int, NetworkSlot]
connect_names: dict[str, tuple[int, int]]
locations: dict[int, dict[int, tuple[int, int, int]]]
checks_in_area: dict[int, dict[str, int | list[int]]]
server_options: dict[str, object]
er_hint_data: dict[int, dict[int, str]]
precollected_items: dict[int, list[int]]
precollected_hints: dict[int, set[Hint]]
version: tuple[int, int, int]
tags: list[str]
minimum_versions: MinimumVersions
seed_name: str
spheres: list[dict[int, set[int]]]
datapackage: dict[str, GamesPackage]
race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:

View File

@@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue

View File

@@ -494,6 +494,30 @@ class Choice(NumericOption):
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -865,13 +889,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
return self.value.__getitem__(item)
return self.value[item]
def __iter__(self) -> typing.Iterator[str]:
return self.value.__iter__()
return iter(self.value)
def __len__(self) -> int:
return self.value.__len__()
return len(self.value)
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
@@ -1067,10 +1091,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)
return self.value[index]
def __len__(self) -> int:
return self.value.__len__()
return len(self.value)
class ConnectionsMeta(AssembleOptions):
@@ -1094,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
percentage: int = 100
@@ -1217,7 +1241,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)
return self.value[index]
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
@@ -1315,6 +1339,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
will be returned as a sorted list.
"""
assert option_names, "options.as_dict() was used without any option names."
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
option_results = {}
for option_name in option_names:
if option_name not in type(self).type_hints:
@@ -1643,7 +1668,7 @@ class OptionGroup(typing.NamedTuple):
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
"""
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to

View File

@@ -80,6 +80,7 @@ Currently, the following games are supported:
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
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

View File

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

View File

@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.2"
__version__ = "0.6.3"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -413,13 +413,23 @@ def get_adjuster_settings(game_name: str) -> Namespace:
@cache_argsless
def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None)
common_path = cache_path("common.json")
if os.path.exists(common_path):
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
else:
common_file = {}
uuid = None
if uuid:
return uuid
import uuid
uuid = uuid.getnode()
persistent_store("client", "uuid", uuid)
from uuid import uuid4
uuid = str(uuid4())
common_file["uuid"] = uuid
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid
@@ -473,6 +483,18 @@ def restricted_loads(s: bytes) -> Any:
return RestrictedUnpickler(io.BytesIO(s)).load()
def restricted_dumps(obj: Any) -> bytes:
"""Helper function analogous to pickle.dumps()."""
s = pickle.dumps(obj)
# Assert that the string can be successfully loaded by restricted_loads
try:
restricted_loads(s)
except pickle.UnpicklingError as e:
raise pickle.PicklingError(e) from e
return s
class ByValue:
"""
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -931,8 +953,7 @@ def _extend_freeze_support() -> None:
# Handle the first process that MP will create
if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
'from multiprocessing.semaphore_tracker import main', # Py<3.8
'from multiprocessing.resource_tracker import main', # Py>=3.8
'from multiprocessing.resource_tracker import main',
'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
):

View File

@@ -54,16 +54,15 @@ def get_app() -> "Flask":
return app
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
def copy_tutorials_files_to_static() -> None:
import shutil
import zipfile
from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, get_file_safe_name(game))
target_path = os.path.join(base_target_path, secure_filename(game))
os.makedirs(target_path, exist_ok=True)
if world.zip_path:
@@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename)
zf.extract(zfile, target_path)
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
f.write(zf.read(zfile))
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
# build dict for the json file
current_tutorial = {
'name': tutorial.tutorial_name,
'description': tutorial.description,
'files': [{
'language': tutorial.language,
'filename': game + '/' + tutorial.file_name,
'link': f'{game}/{tutorial.link}',
'authors': tutorial.authors
}]
}
# check if the name of the current guide exists already
for guide in game_data['tutorials']:
if guide and tutorial.tutorial_name == guide['name']:
guide['files'].append(current_tutorial['files'][0])
break
else:
game_data['tutorials'].append(current_tutorial)
data.append(game_data)
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
generic_data = {}
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
shutil.copyfile(Utils.local_path(source_path, file),
Utils.local_path(target_path, secure_filename(file)))
if __name__ == "__main__":
@@ -142,7 +110,7 @@ if __name__ == "__main__":
logging.warning("Could not update LttP sprites.")
app = get_app()
create_options_files()
create_ordered_tutorials_file()
copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFGEN"]:

View File

@@ -61,30 +61,43 @@ cache = Cache()
Compress(app)
def to_python(value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter):
def to_python(self, value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
return to_python(value)
def to_url(self, value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
return to_url(value)
# short UUID
app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters["title_sorted"] = title_sorted
def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
import importlib
from werkzeug.utils import find_modules
# has automatic patch integration
import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
for module in find_modules("WebHostLib", include_packages=True):
importlib.import_module(module)
from . import api
app.register_blueprint(api.api_endpoints)

View File

@@ -1,11 +1,11 @@
import json
import pickle
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit
from Utils import restricted_dumps
from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400
else:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])

View File

@@ -3,6 +3,7 @@ from uuid import UUID
from flask import abort, url_for
from WebHostLib import to_url
import worlds.Files
from . import api_endpoints, get_players
from ..models import Room
@@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
downloads.append(slot_download)
return {
"tracker": room.tracker,
"tracker": to_url(room.tracker),
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,

View File

@@ -1,6 +1,7 @@
from flask import session, jsonify
from pony.orm import select
from WebHostLib import to_url
from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players
@@ -10,13 +11,13 @@ def get_rooms():
response = []
for room in select(room for room in Room if room.owner == session["_id"]):
response.append({
"room_id": room.id,
"seed_id": room.seed.id,
"room_id": to_url(room.id),
"seed_id": to_url(room.seed.id),
"creation_time": room.creation_time,
"last_activity": room.last_activity,
"last_port": room.last_port,
"timeout": room.timeout,
"tracker": room.tracker,
"tracker": to_url(room.tracker),
})
return jsonify(response)
@@ -26,7 +27,7 @@ def get_seeds():
response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({
"seed_id": seed.id,
"seed_id": to_url(seed.id),
"creation_time": seed.creation_time,
"players": get_players(seed),
})

View File

@@ -164,9 +164,6 @@ def autogen(config: dict):
Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, config: dict, id: int):
self.room_ids = set()

View File

@@ -1,7 +1,7 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
from collections.abc import Set
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
@@ -43,7 +43,7 @@ def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
def get_yaml_data(files) -> dict[str, str] | str | Markup:
options = {}
for uploaded_file in files:
if banned_file(uploaded_file.filename):
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
return options
def roll_options(options: Dict[str, Union[dict, str]],
def roll_options(options: dict[str, dict | str],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
tuple[dict[str, str | bool], dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options))
results = {}
rolled_results = {}
results: dict[str, str | bool] = {}
rolled_results: dict[str, dict] = {}
for filename, text in options.items():
try:
if type(text) is dict:

View File

@@ -129,7 +129,7 @@ class WebHostContext(Context):
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
game_data_packages[game] = restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
@@ -159,6 +159,7 @@ class WebHostContext(Context):
@db_session
def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id)
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again

View File

@@ -1,12 +1,12 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
from collections import Counter
from typing import Any, Dict, List, Optional, Union, Set
from pickle import PicklingError
from typing import Any
from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__
from Utils import __version__, restricted_dumps
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
@@ -23,8 +23,8 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options: Set[str] = set()
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
plando_options: set[str] = set()
for substr in ("bosses", "items", "connections", "texts"):
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
plando_options.add(substr)
@@ -73,7 +73,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
@@ -83,12 +83,18 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
f"If you have a larger group, please generate it yourself and upload it.")
return redirect(url_for(request.endpoint, **(request.view_args or {})))
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
try:
gen = Generation(
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
except PicklingError as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
commit()
return redirect(url_for("wait_seed", seed=gen.id))
@@ -104,9 +110,9 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
if meta is None:
meta = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)

View File

@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites
# Target directories
input_dir = user_path("data", "sprites", "alttpr")
input_dir = user_path("data", "sprites", "alttp", "remote")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)

View File

@@ -7,17 +7,69 @@ from flask import request, redirect, url_for, render_template, Response, session
from pony.orm import count, commit, db_session
from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister
from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
def get_world_theme(game_name: str):
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def get_visible_worlds() -> dict[str, type(World)]:
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return worlds
def render_markdown(path: str) -> str:
import mistune
from collections import Counter
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
import re # there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
return markdown(document)
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
@@ -31,83 +83,94 @@ def start_playing():
return render_template(f"startPlaying.html")
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang):
"""Game Info Pages"""
try:
world = AutoWorldRegister.world_types[game]
if lang not in world.web.game_info_languages:
raise KeyError("Sorry, this game's info page is not available in that language yet.")
except KeyError:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
lang = secure_filename(lang)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, f"{lang}_{secure_game_name}.md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
@cache.cached()
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
"""List of supported games"""
return render_template("supportedGames.html", worlds=get_visible_worlds())
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@app.route('/tutorial/<string:game>/<string:file>')
@cache.cached()
def tutorial(game, file, lang):
def tutorial(game: str, file: str):
try:
world = AutoWorldRegister.world_types[game]
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
raise KeyError("Sorry, the tutorial is not available in that language yet.")
except KeyError:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, file+".md"
))
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
@cache.cached()
def tutorial_landing():
return render_template("tutorialLanding.html")
tutorials = {}
worlds = AutoWorldRegister.world_types
for world_name, world_type in worlds.items():
current_world = tutorials[world_name] = {}
for tutorial in world_type.web.tutorials:
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
"description": tutorial.description, "files": {}})
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
"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)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang: str):
import markdown
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
html_from_markdown=document,
)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def glossary(lang: str):
import markdown
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
html_from_markdown=document,
)

View File

@@ -7,6 +7,5 @@ Flask-Compress>=1.17
Flask-Limiter>=3.12
bokeh>=3.6.3
markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5
mistune>=3.1.3

View File

@@ -1,45 +0,0 @@
window.addEventListener('load', () => {
const gameInfo = document.getElementById('game-info');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, this game's info page is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the info page.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -1,52 +0,0 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('tutorial-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
showdown.setOption('tables', true);
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
header.setAttribute('id', headerId);
header.addEventListener('click', () => {
window.location.hash = `#${headerId}`;
header.scrollIntoView();
});
}
// Manually scroll the user to the appropriate header if anchor navigation is used
document.fonts.ready.finally(() => {
if (window.location.hash) {
const scrollTarget = document.getElementById(window.location.hash.substring(1));
scrollTarget?.scrollIntoView();
}
});
});
});

View File

@@ -1,81 +0,0 @@
const showError = () => {
const tutorial = document.getElementById('tutorial-landing');
document.getElementById('page-title').innerText = 'This page is out of logic!';
tutorial.removeChild(document.getElementById('loading'));
const userMessage = document.createElement('h3');
const homepageLink = document.createElement('a');
homepageLink.innerText = 'Click here';
homepageLink.setAttribute('href', '/');
userMessage.append(homepageLink);
userMessage.append(' to go back to safety!');
tutorial.append(userMessage);
};
window.addEventListener('load', () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
const tutorialDiv = document.getElementById('tutorial-landing');
if (ajax.status !== 200) { return showError(); }
try {
const games = JSON.parse(ajax.responseText);
games.forEach((game) => {
const gameTitle = document.createElement('h2');
gameTitle.innerText = game.gameTitle;
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
tutorialDiv.appendChild(gameTitle);
game.tutorials.forEach((tutorial) => {
const tutorialName = document.createElement('h3');
tutorialName.innerText = tutorial.name;
tutorialDiv.appendChild(tutorialName);
const tutorialDescription = document.createElement('p');
tutorialDescription.innerText = tutorial.description;
tutorialDiv.appendChild(tutorialDescription);
const intro = document.createElement('p');
intro.innerText = 'This guide is available in the following languages:';
tutorialDiv.appendChild(intro);
const fileList = document.createElement('ul');
tutorial.files.forEach((file) => {
const listItem = document.createElement('li');
const anchor = document.createElement('a');
anchor.innerText = file.language;
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
listItem.appendChild(anchor);
listItem.append(' by ');
for (let author of file.authors) {
listItem.append(author);
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
listItem.append(', ');
}
}
fileList.appendChild(listItem);
});
tutorialDiv.appendChild(fileList);
});
});
tutorialDiv.removeChild(document.getElementById('loading'));
} catch (error) {
showError();
console.error(error);
}
// Check if we are on an anchor when coming in, and scroll to it.
const hash = window.location.hash;
if (hash) {
const offset = 128; // To account for navbar banner at top of page.
window.scrollTo(0, 0);
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
window.scrollTo(rect.left, rect.top - offset);
}
};
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -1,4 +1,3 @@
import typing
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
@@ -18,21 +17,23 @@ from .models import Room
PLOT_WIDTH = 600
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
total_games: Counter[str] = Counter()
cutoff = date.today() - timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
if slot.game in known_games:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
current_game = slot.game
else:
current_game = "Other"
total_games[current_game] += 1
games_played[room.creation_time.date()][current_game] += 1
return total_games, games_played
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
def get_color_palette(colors_needed: int) -> list[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
return colors
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
game: str, color: RGB) -> figure:
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
@@ -84,7 +84,7 @@ def stats():
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games):
occurences = []

View File

@@ -1,17 +0,0 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Info</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
<!-- Populated my JS / MD -->
</div>
{% endblock %}

View File

@@ -32,6 +32,9 @@
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>

View File

@@ -1,7 +1,8 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/grassHeader.html' %}
{% set theme_name = theme|default("grass", true) %}
{% include "header/"+theme_name+"Header.html" %}
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/'+theme+'Header.html' %}
<title>Archipelago</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
{% endblock %}
{% block body %}
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
<!-- Content generated by JavaScript -->
</div>
{% endblock %}

View File

@@ -3,14 +3,32 @@
{% block head %}
{% include 'header/grassHeader.html' %}
<title>Archipelago Guides</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/>
{% endblock %}
{% block body %}
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
<h1 id="page-title">Archipelago Guides</h1>
<p id="loading">Loading...</p>
<div id="tutorial-landing" class="markdown">
<h1>Archipelago Guides</h1>
{% for world_name, world_type in worlds.items() %}
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
<h3>{{ tutorial_name }}</h3>
<p>{{ tutorial_data.description }}</p>
<p>This guide is available in the following languages:</p>
<ul>
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endfor %}
</div>
{% endblock %}
{% endblock %}

View File

@@ -1,4 +1,3 @@
import base64
import json
import pickle
import typing
@@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError
import schema
import MultiServer
from NetUtils import SlotType
from NetUtils import GamesPackage, SlotType
from Utils import VersionException, __version__
from worlds import GamesPackage
from worlds.Files import AutoPatchRegister
from worlds.AutoWorld import data_package_checksum
from . import app

View File

@@ -333,6 +333,7 @@ async def nes_sync_task(ctx: ZeldaContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
await asyncio.sleep(1)
continue

View File

@@ -46,7 +46,9 @@ requires:
{{ yaml_dump(game) }}:
{%- for group_name, group_options in option_groups.items() %}
# {{ group_name }}
##{% for _ in group_name %}#{% endfor %}##
# {{ group_name }} #
##{% for _ in group_name %}#{% endfor %}##
{%- for option_key, option in group_options.items() %}
{{ option_key }}:

61
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,61 @@
services:
multiworld:
# Build only once. Web service uses the same image build
build:
context: ..
# Name image for use in web service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch main process without website hosting (config override)
entrypoint: python WebHost.py --config_override selflaunch.yaml
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_selflaunch.yaml:/app/selflaunch.yaml
# Expose on host network for access to dynamically mapped ports
network_mode: host
# No Healthcheck in place yet for multiworld
healthcheck:
test: ["NONE"]
web:
# Use image build by multiworld service
image: archipelago-base
# Use locally-built image
pull_policy: never
# Launch gunicorn targeting WebHost application
entrypoint: gunicorn -c gunicorn.conf.py
volumes:
# Mount application volume
- app_volume:/app
# Mount configs
- ./example_config.yaml:/app/config.yaml
- ./example_gunicorn.conf.py:/app/gunicorn.conf.py
environment:
# Bind gunicorn on 8000
- PORT=8000
nginx:
image: nginx:stable-alpine
volumes:
# Mount application volume
- app_volume:/app
# Mount config
- ./example_nginx.conf:/etc/nginx/nginx.conf
ports:
# Nginx listening internally on port 80 -- mapped to 8080 on host
- 8080:80
depends_on:
- web
volumes:
# Share application directory amongst multiworld and web services
# (for access to log files and the like), and nginx (for static files)
app_volume:

View File

@@ -0,0 +1,10 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# We'll start a separate process for rooms and generators
SELFLAUNCH: false
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
# Set as your local IP (192.168.x.x) to serve over LAN.
HOST_ADDRESS: localhost

View File

@@ -0,0 +1,19 @@
workers = 2
threads = 2
wsgi_app = "WebHost:get_app()"
accesslog = "-"
access_log_format = (
'%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
)
worker_class = "gthread" # "sync" | "gthread"
forwarded_allow_ips = "*"
loglevel = "info"
"""
You can programatically set values.
For example, set number of workers to half of the cpu count:
import multiprocessing
workers = multiprocessing.cpu_count() / 2
"""

64
deploy/example_nginx.conf Normal file
View File

@@ -0,0 +1,64 @@
worker_processes 1;
user nobody nogroup;
# 'user nobody nobody;' for systems with 'nobody' as a group instead
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024; # increase if you have lots of clients
accept_mutex off; # set to 'on' if nginx worker_processes > 1
# 'use epoll;' to enable for Linux 2.6+
# 'use kqueue;' to enable for FreeBSD, OSX
use epoll;
}
http {
include mime.types;
# fallback in case we can't determine a type
default_type application/octet-stream;
access_log /var/log/nginx/access.log combined;
sendfile on;
upstream app_server {
# fail_timeout=0 means we always retry an upstream even if it failed
# to return a good HTTP response
# for UNIX domain socket setups
# server unix:/tmp/gunicorn.sock fail_timeout=0;
# for a TCP configuration
server web:8000 fail_timeout=0;
}
server {
# use 'listen 80 deferred;' for Linux
# use 'listen 80 accept_filter=httpready;' for FreeBSD
listen 80 deferred;
client_max_body_size 4G;
# set the correct host(s) for your site
# server_name example.com www.example.com;
keepalive_timeout 5;
# path for static files
root /app/WebHostLib;
location / {
# checks for static file, if not found proxy to app
try_files $uri @proxy_to_app;
}
location @proxy_to_app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
# we don't want nginx trying to do something clever with
# redirects, we set the Host: header above already.
proxy_redirect off;
proxy_pass http://app_server;
}
}
}

View File

@@ -0,0 +1,13 @@
# Refer to ../docs/webhost configuration sample.yaml
# We'll be hosting VIA gunicorn
SELFHOST: false
# Start room and generator processes
SELFLAUNCH: true
JOB_THRESHOLD: 0
# Maximum concurrent world gens
GENERATORS: 3
# Rooms will be spread across multiple processes
HOSTERS: 4

View File

@@ -136,6 +136,9 @@
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
# Paint
/worlds/paint/ @MarioManTAW
# Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe
@@ -197,7 +200,7 @@
/worlds/timespinner/ @Jarno458
# The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
/worlds/tloz/ @Rosalie-A
# TUNIC
/worlds/tunic/ @silent-destroyer @ScipioWright

View File

@@ -0,0 +1,92 @@
# Deploy Using Containers
If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version.
To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md).
Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy.
## Building the Container Image
What you'll need:
* A container runtime engine such as:
* [Docker](https://www.docker.com/) (Version 23.0 or later)
* [Podman](https://podman.io/) (version 4.0 or later)
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
`docker build -t archipelago .`
Or:
`podman build -t archipelago .`
It is recommended to tag the image using `-t` to more easily identify the image and run it.
## Running the Container
Running the container can be performed using:
`docker run --network host archipelago`
Or:
`podman run --network host archipelago`
The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`.
Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially:
`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml`
See `docs/webhost configuration sample.yaml` for example.
## Using Docker Compose
An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container.
To deploy in this manner, from the ["deploy"](../deploy) directory, run:
`docker compose up -d`
### Services
The `docker-compose.yaml` file defines three services:
* multiworld:
* Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service.
* web:
* Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it.
* We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service.
* No ports are exposed through to the host.
* nginx:
* Serves as a reverse proxy with `web` as its upstream.
* Directs all HTTP traffic from port 80 to the upstream service.
* Exposed to the host on port 8080. This is where we can reach the website.
### Configuration
As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network.
The configuration files may be modified to handle for machine-specific optimizations, such as:
* Web pages responding too slowly
* Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count.
* Game generation stalls
* Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml)
* Gameplay lags
* Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml)
Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`.
## Windows
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
## Optional: A Link to the Past Enemizer
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
error if it is required.
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
Enemizer is not currently available for `aarch64`.
## Optional: Git
Building the image requires a local copy of the ArchipelagoMW source code.
Refer to [Running From Source](running%20from%20source.md#optional-git).

View File

@@ -276,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl
* [Sync](#Sync)
* [LocationChecks](#LocationChecks)
* [LocationScouts](#LocationScouts)
* [CreateHints](#CreateHints)
* [UpdateHint](#UpdateHint)
* [StatusUpdate](#StatusUpdate)
* [Say](#Say)
@@ -294,7 +295,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
| password | str | If the game session requires a password, it should be passed here. |
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| uuid | str | Unique identifier for player. Cached in the user cache \Archipelago\Cache\common.json |
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
@@ -339,7 +340,8 @@ Sent to the server to retrieve the items that are on a specified list of locatio
Fully remote clients without a patch file may use this to "place" items onto their in-game locations, most commonly to display their names or item classifications before/upon pickup.
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code.
#### Arguments
| Name | Type | Notes |
@@ -347,6 +349,21 @@ This is useful in cases where an item appears in the game world, such as 'ledge
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### CreateHints
Sent to the server to create hints for a specified list of locations.
Hints that already exist will be silently skipped and their status will not be updated.
When creating hints for another slot's locations, the packet will fail if any of those locations don't contain items for the requesting slot.
When creating hints for your own slot's locations, non-existing locations will silently be skipped.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| locations | list\[int\] | The ids of the locations to create hints for. |
| player | int | The ID of the player whose locations are being hinted for. Defaults to the requesting slot. |
| status | [HintStatus](#HintStatus) | If included, sets the status of the hint to this status. Defaults to `HINT_UNSPECIFIED`. Cannot set `HINT_FOUND`. |
### UpdateHint
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.

View File

@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
`worlds.alttp.options.py`
`worlds/alttp/Options.py`
### OptionDict
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the

View File

@@ -181,10 +181,3 @@ circular / partial imports. Instead, the code should fetch from settings on dema
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
"global" settings could be used in global scope of worlds.
### APWorld Backwards Compatibility
APWorlds that want to be compatible with both stable and dev versions, have two options:
1. use the old Utils.get_options() API until Archipelago 0.4.2 is out
2. add some sort of compatibility code to your world that mimics the new API

View File

@@ -29,6 +29,10 @@
* New classes, attributes, and methods in core code should have docstrings that follow
[reST style](https://peps.python.org/pep-0287/).
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.
* [Match statements](https://docs.python.org/3/tutorial/controlflow.html#tut-match)
may be used instead of `if`-`elif` if they result in nicer code, or they actually use pattern matching.
Beware of the performance: they are not `goto`s, but `if`-`elif` under the hood, and you may have less control. When
in doubt, just don't use it.
## Markdown

347
docs/webhost api.md Normal file
View File

@@ -0,0 +1,347 @@
# API Guide
Archipelago has a rudimentary API that can be queried by endpoints. The API is a work-in-progress and should be improved over time.
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)
Current endpoints:
- Datapackage API
- [`/datapackage`](#datapackage)
- [`/datapackage/<string:checksum>`](#datapackagestringchecksum)
- [`/datapackage_checksum`](#datapackagechecksum)
- Generation API
- [`/generate`](#generate)
- [`/status/<suuid:seed>`](#status)
- Room API
- [`/room_status/<suuid:room_id>`](#roomstatus)
- User API
- [`/get_rooms`](#getrooms)
- [`/get_seeds`](#getseeds)
## 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.
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`
- A dict of item groups `item_name_groups`
- Item name to AP ID dict `item_name_to_id`
- A dict of location groups `location_name_groups`
- Location name to AP ID dict `location_name_to_id`
Example:
```
{
"games": {
...
"Clique": {
"checksum": "0271f7a80b44ba72187f92815c2bc8669cb464c7",
"item_name_groups": {
"Everything": [
"A Cool Filler Item (No Satisfaction Guaranteed)",
"Button Activation",
"Feeling of Satisfaction"
]
},
"item_name_to_id": {
"A Cool Filler Item (No Satisfaction Guaranteed)": 69696967,
"Button Activation": 69696968,
"Feeling of Satisfaction": 69696969
},
"location_name_groups": {
"Everywhere": [
"The Big Red Button",
"The Item on the Desk"
]
},
"location_name_to_id": {
"The Big Red Button": 69696969,
"The Item on the Desk": 69696968
}
},
...
}
}
```
### `/datapackage/<string:checksum>`
<a name="datapackagestringchecksum"></a>
Fetches a single datapackage by checksum.
Returns a dict of the game's data with:
- A checksum `checksum`
- A dict of item groups `item_name_groups`
- Item name to AP ID dict `item_name_to_id`
- A dict of location groups `location_name_groups`
- Location name to AP ID dict `location_name_to_id`
Its format will be identical to the whole-datapackage endpoint (`/datapackage`), except you'll only be returned the single game's data in a dict.
### `/datapackage_checksum`
<a name="datapackagechecksum"></a>
Fetches the checksums of the current static datapackages on the WebHost.
You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games.
Example:
```
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
...
}
```
## Generation Endpoint
These endpoints are used internally for the WebHost to generate games and validate their generation. They are also used by external applications to generate games automatically.
### `/generate`
<a name="generate"></a>
Submits a game to the WebHost for generation.
**This endpoint only accepts a POST HTTP request.**
There are two ways to submit data for generation: With a file and with JSON.
#### With a file:
Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/generate` endpoint.
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
Example using the python requests library:
```
file = {'file': open('Games.zip', 'rb')}
req = requests.post("https://archipelago.gg/api/generate", files=file)
```
#### With JSON:
Compile your weights/yaml data into a dict. Then insert that into a dict with the key `"weights"`.
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:
```
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
weights={"weights": data}
req = requests.post("https://archipelago.gg/api/generate", json=weights)
```
#### Generation Response:
##### Successful Generation:
Upon successful generation, you'll be sent a JSON dict response detailing the generation:
- The UUID of the generation `detail`
- The SUUID of the generation `encoded`
- The response text `text`
- The page that will resolve to the seed/room generation page once generation has completed `url`
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
Example:
```
{
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
"encoded": "GYePFlpYS3aqt9a_OL6UYw",
"text": "Generation of seed 19878f16-5a58-4b76-aab7-d6bf38be9463 started successfully.",
"url": "http://archipelago.gg/wait/GYePFlpYS3aqt9a_OL6UYw",
"wait_api_url": "http://archipelago.gg/api/status/GYePFlpYS3aqt9a_OL6UYw"
}
```
##### Failed Generation:
Upon failed generation, you'll be returned a single key-value pair. The key will always be `text`
The value will give you a hint as to what may have gone wrong.
- Options without tags, and a 400 status code
- Options in a string, and a 400 status code
- Invalid file/weight string, `No options found. Expected file attachment or json weights.` with a 400 status code
- Too many slots for the server to process, `Max size of multiworld exceeded` with a 409 status code
If the generation detects a issue in generation, you'll be sent a dict with two key-value pairs (`text` and `detail`) and a 400 status code. The values will be:
- Summary of issue in `text`
- 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
### `/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`
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
- Generation of the seed failed: `Generation failed` with a 500 status code
- Generation is in progress still: `Generation running` with a 202 status code
## Room Endpoints
Endpoints to fetch information of the active WebHost room with the supplied room_ID.
### `/room_status/<suuid:room_id>`
<a name="roomstatus"></a>
Will provide a dict of room data with the following keys:
- Tracker SUUID (`tracker`)
- A list of players (`players`)
- Each item containing a list with the Slot name and Game
- Last known hosted port (`last_port`)
- 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`)
Example:
```
{
"downloads": [
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/1",
"slot": 1
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/2",
"slot": 2
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/3",
"slot": 3
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/4",
"slot": 4
},
{
"download": "/slot_file/kK5fmxd8TfisU5Yp_eg/5",
"slot": 5
}
],
"last_activity": "Fri, 18 Apr 2025 20:35:45 GMT",
"last_port": 52122,
"players": [
[
"Slot_Name_1",
"Ocarina of Time"
],
[
"Slot_Name_2",
"Ocarina of Time"
],
[
"Slot_Name_3",
"Ocarina of Time"
],
[
"Slot_Name_4",
"Ocarina of Time"
],
[
"Slot_Name_5",
"Ocarina of Time"
]
],
"timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
}
```
## User Endpoints
User endpoints can get room and seed details from the current session tokens (cookies)
### `/get_rooms`
<a name="getrooms"></a>
Retreives a list of all rooms currently owned by the session token.
Each list item will contain a dict with the room's details:
- Room SUUID (`room_id`)
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
- Last activity timestamp (`last_activity`)
- Last known AP port (`last_port`)
- Room timeout counter in seconds (`timeout`)
- Room tracker SUUID (`tracker`)
Example:
```
[
{
"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",
"timeout": 7200,
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
},
{
"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",
"timeout": 7200,
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
}
]
```
### `/get_seeds`
<a name="getseeds"></a>
Retreives a list of all seeds currently owned by the session token.
Each item in the list will contain a dict with the seed's details:
- Seed SUUID (`seed_id`)
- Creation timestamp (`creation_time`)
- A list of player slots (`players`)
- Each item in the list will contain a list of the slot name and game
Example:
```
[
{
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
"players": [
[
"Slot_Name_1",
"Ocarina of Time"
],
[
"Slot_Name_2",
"Ocarina of Time"
],
[
"Slot_Name_3",
"Ocarina of Time"
],
[
"Slot_Name_4",
"Ocarina of Time"
],
[
"Slot_Name_5",
"Ocarina of Time"
]
],
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
},
{
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
"players": [
[
"Slot_Name_1",
"Clique"
],
[
"Slot_Name_2",
"Clique"
],
[
"Slot_Name_3",
"Clique"
],
[
"Slot_Name_4",
"Archipelago"
]
],
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
}
]
```

View File

@@ -515,6 +515,7 @@ In addition, the following methods can be implemented and are called in this ord
called per player before any items or locations are created. You can set properties on your
world here. Already has access to player options and RNG. This is the earliest step where the world should start
setting up for the current multiworld, as the multiworld itself is still setting up before this point.
You cannot modify `local_items`, or `non_local_items` after this step.
* `create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list.
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
@@ -538,7 +539,7 @@ In addition, the following methods can be implemented and are called in this ord
creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the
item. `location.item.player` can be used to see if it's a local item.
* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
* `fill_slot_data(self)` and `modify_multidata(self, multidata: MultiData)` can be used to modify the data that
will be used by the server to host the MultiWorld.
All instance methods can, optionally, have a class method defined which will be called after all instance methods are
@@ -611,17 +612,10 @@ def create_items(self) -> None:
# If there are two of the same item, the item has to be twice in the pool.
# Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt.
# Having an item in the start inventory won't remove it from the pool.
# If an item can't have duplicates it has to be excluded manually.
# List of items to exclude, as a copy since it will be destroyed below
exclude = [item for item in self.multiworld.precollected_items[self.player]]
# If you want to do that, use start_inventory_from_pool
for item in map(self.create_item, mygame_items):
if item in exclude:
exclude.remove(item) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item("nothing"))
else:
self.multiworld.itempool.append(item)
self.multiworld.itempool.append(item)
# itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk.

View File

@@ -52,13 +52,15 @@ class EntranceLookup:
_coupled: bool
_usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance], targets: Iterable[Entrance]):
self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup()
self._random = rng
self._expands_graph_cache = {}
self._coupled = coupled
self._usable_exits = usable_exits
for target in targets:
self.add(target)
def _can_expand_graph(self, entrance: Entrance) -> bool:
"""
@@ -121,7 +123,14 @@ class EntranceLookup:
dead_end: bool,
preserve_group_order: bool
) -> Iterable[Entrance]:
"""
Gets available targets for the requested groups
:param groups: The groups to find targets for
:param dead_end: Whether to find dead ends. If false, finds non-dead-ends
:param preserve_group_order: Whether to preserve the group order in the returned iterable. If true, a sequence
like AAABBB is guaranteed. If false, groups can be interleaved, e.g. BAABAB.
"""
lookup = self.dead_ends if dead_end else self.others
if preserve_group_order:
for group in groups:
@@ -132,6 +141,27 @@ class EntranceLookup:
self._random.shuffle(ret)
return ret
def find_target(self, name: str, group: int | None = None, dead_end: bool | None = None) -> Entrance | None:
"""
Finds a specific target in the lookup, if it is present.
:param name: The name of the target
:param group: The target's group. Providing this will make the lookup faster, but can be omitted if it is not
known ahead of time for some reason.
:param dead_end: Whether the target is a dead end. Providing this will make the lookup faster, but can be
omitted if this is not known ahead of time (much more likely)
"""
if dead_end is None:
return (found
if (found := self.find_target(name, group, True))
else self.find_target(name, group, False))
lookup = self.dead_ends if dead_end else self.others
targets_to_check = lookup if group is None else lookup[group]
for target in targets_to_check:
if target.name == name:
return target
return None
def __len__(self):
return len(self.dead_ends) + len(self.others)
@@ -146,15 +176,18 @@ class ERPlacementState:
"""The world which is having its entrances randomized"""
collection_state: CollectionState
"""The CollectionState backing the entrance randomization logic"""
entrance_lookup: EntranceLookup
"""A lookup table of all unconnected ER targets"""
coupled: bool
"""Whether entrance randomization is operating in coupled mode"""
def __init__(self, world: World, coupled: bool):
def __init__(self, world: World, entrance_lookup: EntranceLookup, coupled: bool):
self.placements = []
self.pairings = []
self.world = world
self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
self.entrance_lookup = entrance_lookup
@property
def placed_regions(self) -> set[Region]:
@@ -182,6 +215,7 @@ class ERPlacementState:
self.collection_state.stale[self.world.player] = True
self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name))
self.entrance_lookup.remove(target_entrance)
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool:
@@ -311,7 +345,7 @@ def randomize_entrances(
preserve_group_order: bool = False,
er_targets: list[Entrance] | None = None,
exits: list[Entrance] | None = None,
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
on_connect: Callable[[ERPlacementState, list[Entrance], list[Entrance]], bool | None] | None = None
) -> ERPlacementState:
"""
Randomizes Entrances for a single world in the multiworld.
@@ -328,14 +362,18 @@ def randomize_entrances(
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
:param on_connect: A callback function which allows specifying side effects after a placement is completed
successfully and the underlying collection state has been updated.
successfully and the underlying collection state has been updated. The arguments are
1. The ER state
2. The exits placed in this placement pass
3. The entrances they were connected to.
If you use on_connect to make additional placements, you are expected to return True to inform
GER that an additional sweep is needed.
"""
if not world.explicit_indirect_conditions:
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
+ "to correctly analyze whether dead end regions can be required in logic.")
start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True
@@ -351,23 +389,25 @@ def randomize_entrances(
# used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits)
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
for entrance in er_targets:
entrance_lookup.add(entrance)
er_state = ERPlacementState(
world,
EntranceLookup(world.random, coupled, exits_set, er_targets),
coupled
)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration
for entrance in removed_entrances:
entrance_lookup.remove(entrance)
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
# propagate new connections
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
if on_connect:
on_connect(er_state, placed_exits)
change = on_connect(er_state, placed_exits, paired_entrances)
if change:
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
@@ -388,12 +428,12 @@ def randomize_entrances(
# check to see if we are proposing the last placement
if not coupled:
# in uncoupled, this check is easy as there will only be one target.
is_last_placement = len(entrance_lookup) == 1
is_last_placement = len(er_state.entrance_lookup) == 1
else:
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
is_last_placement = len(entrance_lookup) == desired_target_count
is_last_placement = len(er_state.entrance_lookup) == desired_target_count
# if it's not the last placement, we need a sweep
return not is_last_placement
@@ -402,7 +442,7 @@ def randomize_entrances(
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
for target_entrance in er_state.entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
# when requiring new exits, ideally we would like to make it so that every placement increases
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
@@ -420,7 +460,7 @@ def randomize_entrances(
else:
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
# deadlocking is a frequent issue.
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
lookup = er_state.entrance_lookup.dead_ends if dead_end else er_state.entrance_lookup.others
# if we're in a stage where we're trying to get to new regions, we could also enter this
# branch in a success state (when all regions of the preferred type have been placed, but there are still
@@ -466,21 +506,21 @@ def randomize_entrances(
f"All unplaced exits: {unplaced_exits}")
# stage 1 - try to place all the non-dead-end entrances
while entrance_lookup.others:
while er_state.entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True):
break
# stage 2 - try to place all the dead-end entrances
while entrance_lookup.dead_ends:
while er_state.entrance_lookup.dead_ends:
if not find_pairing(dead_end=True, require_new_exits=True):
break
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
# doing this before the non-dead-ends is important to ensure there are enough connections to
# go around
while entrance_lookup.dead_ends:
while er_state.entrance_lookup.dead_ends:
find_pairing(dead_end=True, require_new_exits=False)
# stage 3b - tie all the other loose ends connecting visited regions to each other
while entrance_lookup.others:
while er_state.entrance_lookup.others:
find_pairing(dead_end=False, require_new_exits=False)
running_time = time.perf_counter() - start_time

View File

@@ -53,10 +53,6 @@ Name: "full"; Description: "Full installation"
Name: "minimal"; Description: "Minimal installation"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed
Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full;
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
@@ -76,7 +72,6 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@@ -1,5 +1,5 @@
[pytest]
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported
python_classes = Test
python_functions = test
testpaths =

View File

@@ -9,6 +9,7 @@ import subprocess
import sys
import sysconfig
import threading
import urllib.error
import urllib.request
import warnings
import zipfile
@@ -16,6 +17,10 @@ from collections.abc import Iterable, Sequence
from hashlib import sha3_512
from pathlib import Path
SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fixed
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
requirement = 'cx-Freeze==8.0.0'
try:
@@ -57,7 +62,6 @@ from Utils import version_tuple, is_windows, is_linux
from Cython.Build import cythonize
# On Python < 3.10 LogicMixin is not currently supported.
non_apworlds: set[str] = {
"A Link to the Past",
"Adventure",
@@ -74,9 +78,6 @@ non_apworlds: set[str] = {
"Wargroove",
}
# LogicMixin is broken before 3.10 import revamp
if sys.version_info < (3,10):
non_apworlds.add("Hollow Knight")
def download_SNI() -> None:
print("Updating SNI")
@@ -89,7 +90,8 @@ def download_SNI() -> None:
machine_name = platform.machine().lower()
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
machine_name = "universal" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
sni_version_ref = "latest" if SNI_VERSION == "latest" else f"tags/{SNI_VERSION}"
with urllib.request.urlopen(f"https://api.github.com/repos/alttpo/SNI/releases/{sni_version_ref}") as request:
data = json.load(request)
files = data["assets"]
@@ -103,8 +105,8 @@ def download_SNI() -> None:
# prefer "many" builds
if "many" in download_url:
break
# prefer the correct windows or windows7 build
if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)):
# prefer non-windows7 builds to get up-to-date dependencies
if platform_name == "windows" and "windows7" not in download_url:
break
if source_url and source_url.endswith(".zip"):
@@ -143,15 +145,16 @@ def download_SNI() -> None:
print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: str | None
if os.path.exists("X:/pw.txt"):
print("Using signtool")
with open("X:/pw.txt", encoding="utf-8-sig") as f:
pw = f.read()
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
else:
signtool = None
signtool: str | None = None
try:
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
html = response.read()
if b"status=OK\n" in html:
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
r'/tr http://timestamp.digicert.com/ ')
print("Using signtool")
except (ConnectionError, TimeoutError, urllib.error.URLError) as e:
pass
build_platform = sysconfig.get_platform()
@@ -196,9 +199,10 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
def remove_sprites_from_folder(folder: Path) -> None:
for file in os.listdir(folder):
if file != ".gitignore":
os.remove(folder / file)
if os.path.isdir(folder):
for file in os.listdir(folder):
if file != ".gitignore":
os.remove(folder / file)
def _threaded_hash(filepath: str | Path) -> str:
@@ -407,13 +411,14 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr")
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote")
self.create_manifest()
if is_windows:
# Inno setup stuff
with open("setup.ini", "w") as f:
min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
min_supported_windows = "6.2.9200"
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
with open("installdelete.iss", "w") as f:
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"

View File

@@ -29,14 +29,9 @@ def run_locations_benchmark():
rule_iterations: int = 100_000
if sys.version_info >= (3, 9):
@staticmethod
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
else:
@staticmethod
def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
@staticmethod
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
with TimeIt(f"{test_location.game} {self.rule_iterations} "

66
test/benchmark/match.py Normal file
View File

@@ -0,0 +1,66 @@
"""Micro benchmark comparing match as "switch" with if-elif and dict access"""
from timeit import timeit
def make_match(count: int) -> str:
code = f"for val in range({count}):\n match val:\n"
for n in range(count):
m = n + 1
code += f" case {n}:\n"
code += f" res = {m}\n"
return code
def make_elif(count: int) -> str:
code = f"for val in range({count}):\n"
for n in range(count):
m = n + 1
code += f" {'' if n == 0 else 'el'}if val == {n}:\n"
code += f" res = {m}\n"
return code
def make_dict(count: int, mode: str) -> str:
if mode == "value":
code = "dct = {\n"
for n in range(count):
m = n + 1
code += f" {n}: {m},\n"
code += "}\n"
code += f"for val in range({count}):\n res = dct[val]"
return code
elif mode == "call":
code = ""
for n in range(count):
m = n + 1
code += f"def func{n}():\n val = {m}\n\n"
code += "dct = {\n"
for n in range(count):
code += f" {n}: func{n},\n"
code += "}\n"
code += f"for val in range({count}):\n dct[val]()"
return code
return ""
def timeit_best_of_5(stmt: str, setup: str = "pass") -> float:
"""
Benchmark some code, returning the best of 5 runs.
:param stmt: Code to benchmark
:param setup: Optional code to set up environment
:return: Time taken in microseconds
"""
return min(timeit(stmt, setup, number=10000, globals={}) for _ in range(5)) * 100
def main() -> None:
for count in (3, 5, 8, 10, 20, 30):
print(f"value of {count:-2} with match: {timeit_best_of_5(make_match(count)) / count:.3f} us")
print(f"value of {count:-2} with elif: {timeit_best_of_5(make_elif(count)) / count:.3f} us")
print(f"value of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'value')) / count:.3f} us")
print(f"call of {count:-2} with dict: {timeit_best_of_5(make_dict(count, 'call')) / count:.3f} us")
if __name__ == "__main__":
main()

View File

@@ -69,11 +69,9 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, False)
@@ -92,11 +90,9 @@ class TestEntranceLookup(unittest.TestCase):
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, True)
@@ -112,12 +108,10 @@ class TestEntranceLookup(unittest.TestCase):
for ex in region.exits if not ex.connected_region
and ex.name != "region20_right" and ex.name != "region21_left"])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region and
entrance.name != "region20_right" and entrance.name != "region21_left"]
for entrance in er_targets:
lookup.add(entrance)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
# the top entrance from region 15 should be considered a dead-end
@@ -129,6 +123,56 @@ class TestEntranceLookup(unittest.TestCase):
self.assertTrue(dead_end in lookup.dead_ends)
self.assertEqual(len(lookup.dead_ends), 1)
def test_find_target_by_name(self):
"""Tests that find_target can find the correct target by name only"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right")
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
self.assertIsNone(lookup.find_target("nonexistant"))
def test_find_target_by_name_and_group(self):
"""Tests that find_target can find the correct target by name and group"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong group
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.LEFT))
def test_find_target_by_name_and_group_and_category(self):
"""Tests that find_target can find the correct target by name, group, and dead-endedness"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set, targets=er_targets)
target = lookup.find_target("region0_right", ERTestGroups.RIGHT, False)
self.assertEqual(target.name, "region0_right")
self.assertEqual(target.randomization_group, ERTestGroups.RIGHT)
# wrong deadendedness
self.assertIsNone(lookup.find_target("region0_right", ERTestGroups.RIGHT, True))
class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self):
multiworld = generate_test_multiworld()
@@ -265,12 +309,12 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
def verify_coupled(_: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(2, len(placed_entrances))
self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
seen_placement_count += len(placed_exits)
self.assertEqual(2, len(placed_exits))
self.assertEqual(placed_exits[0].parent_region, placed_exits[1].connected_region)
self.assertEqual(placed_exits[1].parent_region, placed_exits[0].connected_region)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
on_connect=verify_coupled)
@@ -313,10 +357,10 @@ class TestRandomizeEntrances(unittest.TestCase):
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
def verify_uncoupled(state: ERPlacementState, placed_exits: list[Entrance], placed_targets: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(1, len(placed_entrances))
seen_placement_count += len(placed_exits)
self.assertEqual(1, len(placed_exits))
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
on_connect=verify_uncoupled)

View File

@@ -48,13 +48,14 @@ class TestBase(unittest.TestCase):
original_get_all_state = multiworld.get_all_state
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False,
**kwargs):
self.assertTrue(allow_partial_entrances, (
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
"As such, any call to get_all_state must use allow_partial_entrances = True."
))
return original_get_all_state(use_cache, allow_partial_entrances)
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs)
multiworld.get_all_state = patched_get_all_state

View File

@@ -603,6 +603,28 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
self.assertTrue(player3.locations[2].item.advancement)
self.assertTrue(player3.locations[3].item.advancement)
def test_deprioritized_does_not_land_on_priority(self):
multiworld = generate_test_multiworld(1)
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=2)
player1.prog_items[0].classification |= ItemClassification.deprioritized
player1.locations[0].progress_type = LocationProgressType.PRIORITY
distribute_items_restrictive(multiworld)
self.assertFalse(player1.locations[0].item.deprioritized)
def test_deprioritized_still_goes_on_priority_ahead_of_filler(self):
multiworld = generate_test_multiworld(1)
player1 = generate_player_data(multiworld, 1, 2, prog_item_count=1, basic_item_count=1)
player1.prog_items[0].classification |= ItemClassification.deprioritized
player1.locations[0].progress_type = LocationProgressType.PRIORITY
distribute_items_restrictive(multiworld)
self.assertTrue(player1.locations[0].item.advancement)
def test_can_remove_locations_in_fill_hook(self):
"""Test that distribute_items_restrictive calls the fill hook and allows for item and location removal"""
multiworld = generate_test_multiworld()

View File

@@ -148,8 +148,8 @@ class TestBase(unittest.TestCase):
def test_locality_not_modified(self):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
gen_steps = ("generate_early",)
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):

View File

@@ -1,7 +1,8 @@
import unittest
from BaseClasses import MultiWorld, PlandoOptions
from Options import ItemLinks
from BaseClasses import PlandoOptions
from Options import ItemLinks, Choice
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -73,9 +74,10 @@ class TestOptions(unittest.TestCase):
def test_pickle_dumps(self):
"""Test options can be pickled into database for WebHost generation"""
import pickle
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
pickle.dumps(option.from_any(option.default))
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]))

View File

@@ -8,7 +8,12 @@ class TestPackages(unittest.TestCase):
to indicate full package rather than namespace package."""
import Utils
# Ignore directories with these names.
ignore_dirs = {".github"}
worlds_path = Utils.local_path("worlds")
for dirpath, dirnames, filenames in os.walk(worlds_path):
# Drop ignored directories from dirnames, excluding them from walking.
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
with self.subTest(directory=dirpath):
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))

View File

@@ -2,6 +2,8 @@ import re
from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast
from WebHostLib import to_python
if TYPE_CHECKING:
from flask import Flask
from werkzeug.test import Client as FlaskClient
@@ -103,7 +105,7 @@ def stop_room(app_client: "FlaskClient",
poll_interval = 2
print(f"Stopping room {room_id}")
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
room_uuid = to_python(room_id)
if timeout is not None:
sleep(.1) # should not be required, but other things might use threading
@@ -156,7 +158,7 @@ def set_room_timeout(room_id: str, timeout: float) -> None:
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
room_uuid = to_python(room_id)
with db_session:
room: Room = Room.get(id=room_uuid)
room.timeout = timeout
@@ -168,7 +170,7 @@ def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
room_uuid = to_python(room_id)
with db_session:
room: Room = Room.get(id=room_uuid)
return cast(bytes, room.seed.multidata)
@@ -180,7 +182,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
room_uuid = to_python(room_id)
with db_session:
room: Room = Room.get(id=room_uuid)
room.seed.multidata = data

View File

@@ -33,6 +33,15 @@ class TestNumericOptions(unittest.TestCase):
self.assertEqual(choice_option_alias, TestChoice.alias_three)
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
self.assertLess(choice_option_string, "two")
self.assertGreater(choice_option_string, "zero")
self.assertLessEqual(choice_option_string, "one")
self.assertLessEqual(choice_option_string, "two")
self.assertGreaterEqual(choice_option_string, "one")
self.assertGreaterEqual(choice_option_string, "zero")
self.assertGreaterEqual(choice_option_alias, "three")
self.assertRaises(KeyError, TestChoice.from_any, "four")
self.assertIn(choice_option_int, [1, 2, 3])

View File

@@ -2,6 +2,8 @@ import unittest
import Utils
import os
from werkzeug.utils import secure_filename
import WebHost
from worlds.AutoWorld import AutoWorldRegister
@@ -9,36 +11,30 @@ from worlds.AutoWorld import AutoWorldRegister
class TestDocs(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tutorials_data = WebHost.create_ordered_tutorials_file()
WebHost.copy_tutorials_files_to_static()
def test_has_tutorial(self):
games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data)
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
with self.subTest(game_name):
try:
self.assertIn(game_name, games_with_tutorial)
except AssertionError:
# look for partial name in the tutorial name
for game in games_with_tutorial:
if game_name in game:
break
else:
self.fail(f"{game_name} has no setup tutorial. "
f"Games with Tutorial: {games_with_tutorial}")
tutorials = world_type.web.tutorials
self.assertGreater(len(tutorials), 0, msg=f"{game_name} has no setup tutorial.")
safe_name = secure_filename(game_name)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
for tutorial in tutorials:
self.assertTrue(
os.path.isfile(Utils.local_path(target_path, secure_filename(tutorial.file_name))),
f'{game_name} missing tutorial file {tutorial.file_name}.'
)
def test_has_game_info(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
safe_name = Utils.get_file_safe_name(game_name)
safe_name = secure_filename(game_name)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
for game_info_lang in world_type.web.game_info_languages:
with self.subTest(game_name):
self.assertTrue(
safe_name == game_name or
not os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{game_name}.md')),
f'Info docs have be named <lang>_{safe_name}.md for {game_name}.'
)
self.assertTrue(
os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{safe_name}.md')),
f'{game_name} missing game info file for "{game_info_lang}" language.'

View File

@@ -29,8 +29,3 @@ class TestFileGeneration(unittest.TestCase):
with open(file, encoding="utf-8-sig") as f:
for value in roll_options({file.name: f.read()})[0].values():
self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.")
def test_tutorial(self):
WebHost.create_ordered_tutorials_file()
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json")))
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json")))

View File

@@ -16,7 +16,7 @@ from Utils import deprecate
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
from . import GamesPackage
from NetUtils import GamesPackage, MultiData
from settings import Group
perf_logger = logging.getLogger("performance")
@@ -72,15 +72,6 @@ class AutoWorldRegister(type):
dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"])
# create missing options_dataclass from legacy option_definitions
# TODO - remove this once all worlds use options dataclasses
if "options_dataclass" not in dct and "option_definitions" in dct:
# TODO - switch to deprecate after a version
deprecate(f"{name} Assigned options through option_definitions which is now deprecated. "
"Please use options_dataclass instead.")
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
bases=(PerGameCommonOptions,))
# construct class
new_class = super().__new__(mcs, name, bases, dct)
new_class.__file__ = sys.modules[new_class.__module__].__file__
@@ -450,7 +441,7 @@ class World(metaclass=AutoWorldRegister):
"""
pass
def modify_multidata(self, multidata: Dict[str, Any]) -> None: # TODO: TypedDict for multidata?
def modify_multidata(self, multidata: "MultiData") -> None:
"""For deeper modification of server multidata."""
pass
@@ -493,9 +484,6 @@ class World(metaclass=AutoWorldRegister):
Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these.
"""
# TODO remove loop when worlds use options dataclass
for option_key, option in cls.options_dataclass.type_hints.items():
getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default)
group = cls(multiworld, new_player_id)
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
for option_key, option in cls.options_dataclass.type_hints.items()})

View File

@@ -15,7 +15,6 @@ import bsdiff4
semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading
del os
class AutoPatchRegister(abc.ABCMeta):
@@ -34,10 +33,8 @@ class AutoPatchRegister(abc.ABCMeta):
@staticmethod
def get_handler(file: str) -> Optional[AutoPatchRegister]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
return None
_, suffix = os.path.splitext(file)
return AutoPatchRegister.file_endings.get(suffix, None)
class AutoPatchExtensionRegister(abc.ABCMeta):

View File

@@ -7,8 +7,9 @@ import warnings
import zipimport
import time
import dataclasses
from typing import Dict, List, TypedDict
from typing import List
from NetUtils import DataPackage
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
@@ -24,8 +25,6 @@ __all__ = {
"world_sources",
"local_folder",
"user_folder",
"GamesPackage",
"DataPackage",
"failed_world_loads",
}
@@ -33,18 +32,6 @@ __all__ = {
failed_world_loads: List[str] = []
class GamesPackage(TypedDict, total=False):
item_name_groups: Dict[str, List[str]]
item_name_to_id: Dict[str, int]
location_name_groups: Dict[str, List[str]]
location_name_to_id: Dict[str, int]
checksum: str
class DataPackage(TypedDict):
games: Dict[str, GamesPackage]
@dataclasses.dataclass(order=True)
class WorldSource:
path: str # typically relative path from this module
@@ -76,9 +63,7 @@ class WorldSource:
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
# Found no equivalent for < 3.10
if hasattr(importer, "exec_module"):
importer.exec_module(mod)
importer.exec_module(mod)
else:
importlib.import_module(f".{self.path}", "worlds")
self.time_taken = time.perf_counter()-start

View File

@@ -4,16 +4,18 @@ checking or launching the client, otherwise it will probably cause circular impo
"""
import asyncio
import copy
import enum
import subprocess
from typing import Any
import settings
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
import Patch
import Utils
from . import BizHawkContext, ConnectionStatus, NotConnectedError, RequestFailedError, connect, disconnect, get_hash, \
get_script_version, get_system, ping
get_script_version, get_system, ping, display_message
from .client import BizHawkClient, AutoBizHawkClientRegister
@@ -27,20 +29,97 @@ class AuthStatus(enum.IntEnum):
AUTHENTICATED = 3
class TextCategory(str, enum.Enum):
ALL = "all"
INCOMING = "incoming"
OUTGOING = "outgoing"
OTHER = "other"
HINT = "hint"
CHAT = "chat"
SERVER = "server"
class BizHawkClientCommandProcessor(ClientCommandProcessor):
def _cmd_bh(self):
"""Shows the current status of the client's connection to BizHawk"""
if isinstance(self.ctx, BizHawkClientContext):
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
logger.info("BizHawk Connection Status: Not Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
logger.info("BizHawk Connection Status: Tentatively Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
logger.info("BizHawk Connection Status: Connected")
assert isinstance(self.ctx, BizHawkClientContext)
if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
logger.info("BizHawk Connection Status: Not Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
logger.info("BizHawk Connection Status: Tentatively Connected")
elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
logger.info("BizHawk Connection Status: Connected")
def _cmd_toggle_text(self, category: str | None = None, toggle: str | None = None):
"""Sets types of incoming messages to forward to the emulator"""
assert isinstance(self.ctx, BizHawkClientContext)
if category is None:
logger.info("Usage: /toggle_text category [toggle]\n\n"
"category: incoming, outgoing, other, hint, chat, and server\n"
"Or \"all\" to toggle all categories at once\n\n"
"toggle: on, off, true, or false\n"
"Or omit to set it to the opposite of its current state\n\n"
"Example: /toggle_text outgoing on")
return
category = category.lower()
value: bool | None
if toggle is None:
value = None
elif toggle.lower() in ("on", "true"):
value = True
elif toggle.lower() in ("off", "false"):
value = False
else:
logger.info(f'Unknown value "{toggle}", should be on|off|true|false')
return
valid_categories = (
TextCategory.ALL,
TextCategory.OTHER,
TextCategory.INCOMING,
TextCategory.OUTGOING,
TextCategory.HINT,
TextCategory.CHAT,
TextCategory.SERVER,
)
if category not in valid_categories:
logger.info(f'Unknown value "{category}", should be {"|".join(valid_categories)}')
return
if category == TextCategory.ALL:
if value is None:
logger.info('Must specify "on" or "off" for category "all"')
return
if value:
self.ctx.text_passthrough_categories.update((
TextCategory.OTHER,
TextCategory.INCOMING,
TextCategory.OUTGOING,
TextCategory.HINT,
TextCategory.CHAT,
TextCategory.SERVER,
))
else:
self.ctx.text_passthrough_categories.clear()
else:
if value is None:
value = category not in self.ctx.text_passthrough_categories
if value:
self.ctx.text_passthrough_categories.add(category)
else:
self.ctx.text_passthrough_categories.remove(category)
logger.info(f"Currently Showing Categories: {', '.join(self.ctx.text_passthrough_categories)}")
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
text_passthrough_categories: set[str]
server_seed_name: str | None = None
auth_status: AuthStatus
password_requested: bool
@@ -54,12 +133,33 @@ class BizHawkClientContext(CommonContext):
def __init__(self, server_address: str | None, password: str | None):
super().__init__(server_address, password)
self.text_passthrough_categories = set()
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.password_requested = False
self.client_handler = None
self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5
def _categorize_text(self, args: dict) -> TextCategory:
if "type" not in args or args["type"] in {"Hint", "Join", "Part", "TagsChanged", "Goal", "Release", "Collect",
"Countdown", "ServerChat", "ItemCheat"}:
return TextCategory.SERVER
elif args["type"] == "Chat":
return TextCategory.CHAT
elif args["type"] == "ItemSend":
if args["item"].player == self.slot:
return TextCategory.OUTGOING
elif args["receiving"] == self.slot:
return TextCategory.INCOMING
else:
return TextCategory.OTHER
def on_print_json(self, args: dict):
super().on_print_json(args)
if self.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
if self._categorize_text(args) in self.text_passthrough_categories:
Utils.async_start(display_message(self.bizhawk_ctx, self.rawjsontotextparser(copy.deepcopy(args["data"]))))
def make_gui(self):
ui = super().make_gui()
ui.base_title = "Archipelago BizHawk Client"
@@ -205,10 +305,10 @@ async def _game_watcher(ctx: BizHawkClientContext):
async def _run_game(rom: str):
import os
auto_start = Utils.get_settings().bizhawkclient_options.rom_start
auto_start = settings.get_settings().bizhawkclient_options.rom_start
if auto_start is True:
emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path
emuhawk_path = settings.get_settings().bizhawkclient_options.emuhawk_path
subprocess.Popen(
[
emuhawk_path,

View File

@@ -34,7 +34,7 @@ class AWebInTime(WebWorld):
"Multiworld Setup Guide",
"A guide for setting up A Hat in Time to be played in Archipelago.",
"English",
"ahit_en.md",
"setup_en.md",
"setup/en",
["CookieCat"]
)]
@@ -260,11 +260,7 @@ class HatInTimeWorld(World):
f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})")
slot_data["ShopItemNames"] = shop_item_names
for name, value in self.options.as_dict(*self.options_dataclass.type_hints).items():
if name in slot_data_options:
slot_data[name] = value
slot_data.update(self.options.as_dict(*slot_data_options))
return slot_data
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):

View File

@@ -209,8 +209,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
if localized:
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
if in_dungeon_items:
restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if
restricted}
restricted_players = {world.player for world in multiworld.get_game_worlds("A Link to the Past") if
world.options.restrict_dungeon_item_on_boss}
locations: typing.List["ALttPLocation"] = [
location for location in get_unfilled_dungeon_locations(multiworld)
# filter boss
@@ -255,8 +255,9 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
if all_state_base.has("Triforce", player):
all_state_base.remove(multiworld.worlds[player].create_item("Triforce"))
for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items():
if not key_drop_shuffle and player not in multiworld.groups:
for lttp_world in multiworld.get_game_worlds("A Link to the Past"):
if not lttp_world.options.key_drop_shuffle and lttp_world.player not in multiworld.groups:
player = lttp_world.player
for key_loc in key_drop_data:
key_data = key_drop_data[key_loc]
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))

View File

@@ -223,7 +223,7 @@ items_reduction_table = (
def generate_itempool(world):
player = world.player
player: int = world.player
multiworld = world.multiworld
if world.options.item_pool.current_key not in difficulties:
@@ -280,7 +280,6 @@ def generate_itempool(world):
if multiworld.custom:
pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = (
make_custom_item_pool(multiworld, player))
multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999)
else:
(pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
additional_triforce_pieces) = get_pool_core(multiworld, player)
@@ -386,8 +385,8 @@ def generate_itempool(world):
if world.options.retro_bow:
shop_items = 0
shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if
shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if
shop_locations = [location for shop_locations in (shop.region.locations for shop in world.shops if
shop.type == ShopType.Shop) for location in shop_locations if
location.shop_slot is not None]
for location in shop_locations:
if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow":
@@ -546,7 +545,7 @@ def set_up_take_anys(multiworld, world, player):
connect_entrance(multiworld, entrance.name, old_man_take_any.name, player)
entrance.target = 0x58
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
multiworld.shops.append(old_man_take_any.shop)
world.shops.append(old_man_take_any.shop)
sword_indices = [
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
@@ -574,7 +573,7 @@ def set_up_take_anys(multiworld, world, player):
connect_entrance(multiworld, entrance.name, take_any.name, player)
entrance.target = target
take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1)
multiworld.shops.append(take_any.shop)
world.shops.append(take_any.shop)
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0)
location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import Utils
import settings
import worlds.Files
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
@@ -514,7 +515,8 @@ def _populate_sprite_table():
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
with concurrent.futures.ThreadPoolExecutor() as pool:
sprite_paths = [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')]
sprite_paths = [user_path("data", "sprites", "alttp", "remote"),
user_path("data", "sprites", "alttp", "custom")]
for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]:
for file in os.listdir(dir):
pool.submit(load_sprite_from_file, os.path.join(dir, file))
@@ -1001,14 +1003,19 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
# set light cones
rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00)
rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00)
rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00)
# light world light cone
rom.write_byte(0x180039, local_world.light_world_light_cone)
# dark world light cone
rom.write_byte(0x18003A, local_world.dark_world_light_cone)
GREEN_TWENTY_RUPEES = 0x47
GREEN_CLOCK = item_table["Green Clock"].item_code
rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on
# Rupoor negative value
rom.write_int16(0x180036, local_world.rupoor_cost)
# handle item_functionality
if local_world.options.item_functionality == 'hard':
rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon
@@ -1026,8 +1033,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
# Disable catching fairies
rom.write_byte(0x34FD6, 0x80)
overflow_replacement = GREEN_TWENTY_RUPEES
# Rupoor negative value
rom.write_int16(0x180036, world.rupoor_cost)
# Set stun items
rom.write_byte(0x180180, 0x02) # Hookshot only
elif local_world.options.item_functionality == 'expert':
@@ -1046,8 +1051,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
# Disable catching fairies
rom.write_byte(0x34FD6, 0x80)
overflow_replacement = GREEN_TWENTY_RUPEES
# Rupoor negative value
rom.write_int16(0x180036, world.rupoor_cost)
# Set stun items
rom.write_byte(0x180180, 0x00) # Nothing
else:
@@ -1065,8 +1068,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
rom.write_byte(0x18004F, 0x01)
# Enable catching fairies
rom.write_byte(0x34FD6, 0xF0)
# Rupoor negative value
rom.write_int16(0x180036, world.rupoor_cost)
# Set stun items
rom.write_byte(0x180180, 0x03) # All standard items
# Set overflow items for progressive equipment
@@ -1312,7 +1313,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0
rom.write_byte(0xF5D73, 0xF0) # bees are catchable
rom.write_byte(0xF5F10, 0xF0) # bees are catchable
rom.write_byte(0x180086, 0x00 if world.aga_randomness else 0x01) # set blue ball and ganon warp randomness
rom.write_byte(0x180086, 0x00) # set blue ball and ganon warp randomness
rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror
rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp
rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00)
@@ -1617,7 +1618,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills
rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix
rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room
rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
rom.write_byte(0x180042, 0x01 if local_world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
# remove shield from uncle
rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E])
@@ -1738,8 +1739,7 @@ def get_price_data(price: int, price_type: int) -> List[int]:
def write_custom_shops(rom, world, player):
shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player],
key=lambda shop: shop.sram_offset)
shops = sorted([shop for shop in world.worlds[player].shops if shop.custom], key=lambda shop: shop.sram_offset)
shop_data = bytearray()
items_data = bytearray()
@@ -3023,7 +3023,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def get_base_rom_path(file_name: str = "") -> str:
options = Utils.get_settings()
options = settings.get_settings()
if not file_name:
file_name = options["lttp_options"]["rom_file"]
if not os.path.exists(file_name):

View File

@@ -147,7 +147,6 @@ def set_defeat_dungeon_boss_rule(location):
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
def set_always_allow(spot, rule):
spot.always_allow = rule
@@ -463,12 +462,15 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Misery Mire - Big Chest', player), lambda state: state.has('Big Key (Misery Mire)', player))
set_rule(multiworld.get_location('Misery Mire - Spike Chest', player), lambda state: (world.can_take_damage and has_hearts(state, player, 4)) or state.has('Cane of Byrna', player) or state.has('Cape', player))
set_rule(multiworld.get_entrance('Misery Mire Big Key Door', player), lambda state: state.has('Big Key (Misery Mire)', player))
# How to access crystal switch:
# If have big key: then you will need 2 small keys to be able to hit switch and return to main area, as you can burn key in dark room
# If not big key: cannot burn key in dark room, hence need only 1 key. all doors immediately available lead to a crystal switch.
# The listed chests are those which can be reached if you can reach a crystal switch.
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 2))
# The most number of keys you can burn without opening the map chest and without reaching a crystal switch is 1,
# but if you cannot activate a crystal switch except by throwing a pot, you could burn another two going through
# the conveyor crystal room.
set_rule(multiworld.get_location('Misery Mire - Map Chest', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 2) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 4))
# Using a key on the map door chest will get you the map chest but not a crystal switch. Main Lobby should require
# one more key.
set_rule(multiworld.get_location('Misery Mire - Main Lobby', player), lambda state: (state._lttp_has_key('Small Key (Misery Mire)', player, 3) and can_activate_crystal_switch(state, player)) or state._lttp_has_key('Small Key (Misery Mire)', player, 5))
# we can place a small key in the West wing iff it also contains/blocks the Big Key, as we cannot reach and softlock with the basement key door yet
set_rule(multiworld.get_location('Misery Mire - Conveyor Crystal Key Drop', player),
lambda state: state._lttp_has_key('Small Key (Misery Mire)', player, 4)
@@ -542,6 +544,8 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player))
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player))
set_rule(multiworld.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
set_rule(multiworld.get_location('Ganons Tower - Double Switch Pot Key', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state.has('Cane of Somaria', player) or can_use_bombs(state, player))
if world.options.pot_shuffle:
set_rule(multiworld.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
set_rule(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
@@ -975,18 +979,19 @@ def check_is_dark_world(region):
return False
def add_conditional_lamps(world, player):
def add_conditional_lamps(multiworld, player):
# Light cones in standard depend on which world we actually are in, not which one the location would normally be
# We add Lamp requirements only to those locations which lie in the dark world (or everything if open
local_world = multiworld.worlds[player]
def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False):
if (not world.dark_world_light_cone and check_is_dark_world(world.get_region(region, player))) or (
not world.light_world_light_cone and not check_is_dark_world(world.get_region(region, player))):
if (not local_world.dark_world_light_cone and check_is_dark_world(local_world.get_region(region))) or (
not local_world.light_world_light_cone and not check_is_dark_world(local_world.get_region(region))):
if spottype == 'Location':
spot = world.get_location(spot, player)
spot = local_world.get_location(spot)
else:
spot = world.get_entrance(spot, player)
add_lamp_requirement(world, spot, player, accessible_torch)
spot = local_world.get_entrance(spot)
add_lamp_requirement(multiworld, spot, player, accessible_torch)
add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance')
add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance')
@@ -997,7 +1002,7 @@ def add_conditional_lamps(world, player):
'Location', True)
add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)',
'Location', True)
if world.worlds[player].options.mode != 'inverted':
if multiworld.worlds[player].options.mode != 'inverted':
add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance')
add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower')
add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower')
@@ -1019,10 +1024,10 @@ def add_conditional_lamps(world, player):
add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True)
add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True)
if not world.worlds[player].options.mode == "standard":
add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player)
add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player)
add_lamp_requirement(world, world.get_entrance('Throne Room', player), player)
if not multiworld.worlds[player].options.mode == "standard":
add_lamp_requirement(multiworld, local_world.get_location("Sewers - Dark Cross"), player)
add_lamp_requirement(multiworld, local_world.get_entrance("Sewers Back Door"), player)
add_lamp_requirement(multiworld, local_world.get_entrance("Throne Room"), player)
def open_rules(world, player):

View File

@@ -14,8 +14,6 @@ from .Items import item_name_groups
from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows
logger = logging.getLogger("Shops")
@unique
class ShopType(IntEnum):
@@ -162,7 +160,10 @@ shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop,
def push_shop_inventories(multiworld):
shop_slots = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop.type
all_shops = []
for world in multiworld.get_game_worlds(ALttPLocation.game):
all_shops.extend(world.shops)
shop_slots = [location for shop_locations in (shop.region.locations for shop in all_shops if shop.type
!= ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None]
for location in shop_slots:
@@ -178,7 +179,7 @@ def push_shop_inventories(multiworld):
get_price(multiworld, location.shop.inventory[location.shop_slot], location.player,
location.shop_price_type)[1])
for world in multiworld.get_game_worlds("A Link to the Past"):
for world in multiworld.get_game_worlds(ALttPLocation.game):
world.pushed_shop_inventories.set()
@@ -225,7 +226,7 @@ def create_shops(multiworld, player: int):
if locked is None:
shop.locked = True
region.shop = shop
multiworld.shops.append(shop)
multiworld.worlds[player].shops.append(shop)
for index, item in enumerate(inventory):
shop.add_inventory(index, *item)
if not locked and (num_slots or type == ShopType.UpgradeShop):
@@ -309,50 +310,50 @@ def set_up_shops(multiworld, player: int):
from .Options import small_key_shuffle
# TODO: move hard+ mode changes for shields here, utilizing the new shops
if multiworld.worlds[player].options.retro_bow:
local_world = multiworld.worlds[player]
if local_world.options.retro_bow:
rss = multiworld.get_region('Red Shield Shop', player).shop
# Can't just replace the single arrow with 10 arrows as retro doesn't need them.
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
['Blue Shield', 50], ['Small Heart',
10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them.
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
['Blue Shield', 50], ['Small Heart', 10]]
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
replacement_items.append(['Small Key (Universal)', 100])
replacement_item = multiworld.random.choice(replacement_items)
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
rss.locked = True
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal or multiworld.worlds[player].options.retro_bow:
for shop in multiworld.random.sample([s for s in multiworld.shops if
s.custom and not s.locked and s.type == ShopType.Shop
and s.region.player == player], 5):
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal or local_world.options.retro_bow:
for shop in multiworld.random.sample([s for s in local_world.shops if
s.custom and not s.locked and s.type == ShopType.Shop], 5):
shop.locked = True
slots = [0, 1, 2]
multiworld.random.shuffle(slots)
slots = iter(slots)
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
if multiworld.worlds[player].options.retro_bow:
if local_world.options.retro_bow:
shop.push_inventory(next(slots), 'Single Arrow', 80)
if multiworld.worlds[player].options.shuffle_capacity_upgrades:
for shop in multiworld.shops:
if shop.type == ShopType.UpgradeShop and shop.region.player == player and \
if local_world.options.shuffle_capacity_upgrades:
for shop in local_world.shops:
if shop.type == ShopType.UpgradeShop and \
shop.region.name == "Capacity Upgrade":
shop.clear_inventory()
if (multiworld.worlds[player].options.shuffle_shop_inventories or multiworld.worlds[player].options.randomize_shop_prices
or multiworld.worlds[player].options.randomize_cost_types):
if (local_world.options.shuffle_shop_inventories or local_world.options.randomize_shop_prices
or local_world.options.randomize_cost_types):
shops = []
total_inventory = []
for shop in multiworld.shops:
if shop.region.player == player:
if shop.type == ShopType.Shop and not shop.locked:
shops.append(shop)
total_inventory.extend(shop.inventory)
for shop in local_world.shops:
if shop.type == ShopType.Shop and not shop.locked:
shops.append(shop)
total_inventory.extend(shop.inventory)
for item in total_inventory:
item["price_type"], item["price"] = get_price(multiworld, item, player)
if multiworld.worlds[player].options.shuffle_shop_inventories:
if local_world.options.shuffle_shop_inventories:
multiworld.random.shuffle(total_inventory)
i = 0
@@ -407,7 +408,7 @@ price_rate_display = {
}
def get_price_modifier(item):
def get_price_modifier(item) -> float:
if item.game == "A Link to the Past":
if any(x in item.name for x in
['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']):
@@ -418,9 +419,9 @@ def get_price_modifier(item):
elif any(x in item.name for x in ['Small Key', 'Heart']):
return 0.5
else:
return 1
return 1.0
if item.advancement:
return 1
return 1.0
elif item.useful:
return 0.5
else:
@@ -471,7 +472,7 @@ def get_price(multiworld, item, player: int, price_type=None):
def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation):
if location.shop_price_type == ShopPriceType.Hearts:
return has_hearts(state, player, (location.shop_price / 8) + 1)
return has_hearts(state, player, (location.shop_price // 8) + 1)
elif location.shop_price_type == ShopPriceType.Bombs:
return can_use_bombs(state, player, location.shop_price)
elif location.shop_price_type == ShopPriceType.Arrows:

View File

@@ -14,13 +14,13 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
shop in state.multiworld.shops)
return any(shop.has_unlimited(item) and shop.region.can_reach(state) for
shop in state.multiworld.worlds[player].shops)
def can_buy(state: CollectionState, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
shop in state.multiworld.shops)
return any(shop.has(item) and shop.region.can_reach(state) for
shop in state.multiworld.worlds[player].shops)
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:

View File

@@ -236,6 +236,8 @@ class ALTTPWorld(World):
required_client_version = (0, 4, 1)
web = ALTTPWeb()
shops: list[Shop]
pedestal_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
sickkid_credit_texts: typing.Dict[int, str] = \
@@ -282,6 +284,10 @@ class ALTTPWorld(World):
clock_mode: str = ""
treasure_hunt_required: int = 0
treasure_hunt_total: int = 0
light_world_light_cone: bool = False
dark_world_light_cone: bool = False
save_and_quit_from_boss: bool = True
rupoor_cost: int = 10
def __init__(self, *args, **kwargs):
self.dungeon_local_item_names = set()
@@ -298,6 +304,7 @@ class ALTTPWorld(World):
self.fix_trock_exit = None
self.required_medallions = ["Ether", "Quake"]
self.escape_assist = []
self.shops = []
super(ALTTPWorld, self).__init__(*args, **kwargs)
@classmethod
@@ -505,10 +512,11 @@ class ALTTPWorld(World):
def pre_fill(self):
from Fill import fill_restrictive, FillError
attempts = 5
all_state = self.multiworld.get_all_state(use_cache=False)
all_state = self.multiworld.get_all_state(perform_sweep=False)
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
for crystal in crystals:
all_state.remove(crystal)
all_state.sweep_for_advancements()
crystal_locations = [self.get_location('Turtle Rock - Prize'),
self.get_location('Eastern Palace - Prize'),
self.get_location('Desert Palace - Prize'),
@@ -799,7 +807,7 @@ class ALTTPWorld(World):
return shop_data
if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]:
if shop_info := [build_shop_info(shop) for shop in self.shops if shop.custom]:
spoiler_handle.write('\n\nShops:\n\n')
for shop_data in shop_info:
spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join(

View File

@@ -32,8 +32,8 @@ class TestMiseryMire(TestDungeon):
["Misery Mire - Main Lobby", False, []],
["Misery Mire - Main Lobby", False, [], ['Pegasus Boots', 'Hookshot']],
["Misery Mire - Main Lobby", False, [], ['Small Key (Misery Mire)', 'Big Key (Misery Mire)']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Hookshot', 'Progressive Sword']],
["Misery Mire - Main Lobby", True, ['Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Small Key (Misery Mire)', 'Pegasus Boots', 'Progressive Sword']],
["Misery Mire - Big Key Chest", False, []],
["Misery Mire - Big Key Chest", False, [], ['Fire Rod', 'Lamp']],

View File

@@ -207,11 +207,7 @@ class EnemyScaling(DefaultOnToggle):
class BlasphemousDeathLink(DeathLink):
"""
When you die, everyone dies. The reverse is also true.
Note that Guilt Fragments will not appear when killed by Death Link.
"""
__doc__ = DeathLink.__doc__ + "\n\n Note that Guilt Fragments will not appear when killed by death link."
@dataclass

View File

@@ -175,11 +175,7 @@ class DamageMultiplier(Range):
class BRCDeathLink(DeathLink):
"""
When you die, everyone dies. The reverse is also true.
This can be changed later in the options menu inside the Archipelago phone app.
"""
__doc__ = DeathLink.__doc__ + "\n\n This can be changed later in the options menu inside the Archipelago phone app."
@dataclass

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from Options import (OptionGroup, Choice, DefaultOnToggle, ItemsAccessibility, PerGameCommonOptions, Range, Toggle,
StartInventoryPool)
StartInventoryPool, DeathLink)
class CharacterStages(Choice):
@@ -507,12 +507,11 @@ class WindowColorA(Range):
default = 8
class DeathLink(Choice):
"""
When you die, everyone dies. Of course the reverse is true too.
Explosive: Makes received DeathLinks kill you via the Magical Nitro explosion instead of the normal death animation.
"""
display_name = "DeathLink"
class CV64DeathLink(Choice):
__doc__ = (DeathLink.__doc__ + "\n\n Explosive: Makes received death links kill you via the Magical Nitro " +
"explosion instead of the normal death animation.")
display_name = "Death Link"
option_off = 0
alias_no = 0
alias_true = 1
@@ -575,7 +574,7 @@ class CV64Options(PerGameCommonOptions):
map_lighting: MapLighting
fall_guard: FallGuard
cinematic_experience: CinematicExperience
death_link: DeathLink
death_link: CV64DeathLink
cv64_option_groups = [
@@ -584,7 +583,7 @@ cv64_option_groups = [
RenonFightCondition, VincentFightCondition, BadEndingCondition, IncreaseItemLimit, NerfHealingItems,
LoadingZoneHeals, InvisibleItems, DropPreviousSubWeapon, PermanentPowerUps, IceTrapPercentage,
IceTrapAppearance, DisableTimeRestrictions, SkipGondolas, SkipWaterwayBlocks, Countdown, BigToss, PantherDash,
IncreaseShimmySpeed, FallGuard, DeathLink
IncreaseShimmySpeed, FallGuard, CV64DeathLink
]),
OptionGroup("cosmetics", [
WindowColorR, WindowColorG, WindowColorB, WindowColorA, BackgroundMusic, MapLighting, CinematicExperience

View File

@@ -16,7 +16,7 @@ from .text import cv64_string_to_bytearray, cv64_text_truncate, cv64_text_wrap
from .aesthetics import renon_item_dialogue, get_item_text_color
from .locations import get_location_info
from .options import CharacterStages, VincentFightCondition, RenonFightCondition, PostBehemothBoss, RoomOfClocksBoss, \
BadEndingCondition, DeathLink, DraculasCondition, InvisibleItems, Countdown, PantherDash
BadEndingCondition, CV64DeathLink, DraculasCondition, InvisibleItems, Countdown, PantherDash
from settings import get_settings
if TYPE_CHECKING:
@@ -356,7 +356,7 @@ class CV64PatchExtensions(APPatchExtension):
rom_data.write_int32s(0xBFE190, patches.subweapon_surface_checker)
# Make received DeathLinks blow you to smithereens instead of kill you normally.
if options["death_link"] == DeathLink.option_explosive:
if options["death_link"] == CV64DeathLink.option_explosive:
rom_data.write_int32s(0xBFC0D0, patches.deathlink_nitro_edition)
rom_data.write_int32(0x27A70, 0x10000008) # B [forward 0x08]
rom_data.write_int32(0x27AA0, 0x0C0FFA78) # JAL 0x803FE9E0
@@ -365,7 +365,7 @@ class CV64PatchExtensions(APPatchExtension):
rom_data.write_int32(0x32DBC, 0x00000000)
# Set the DeathLink ROM flag if it's on at all.
if options["death_link"] != DeathLink.option_off:
if options["death_link"] != CV64DeathLink.option_off:
rom_data.write_byte(0xBFBFDE, 0x01)
# DeathLink counter decrementer code

View File

@@ -267,6 +267,10 @@ class DarkSouls3World(World):
# Don't allow missable duplicates of progression items to be expected progression.
if location.name in self.missable_dupe_prog_locs: continue
# Don't create DLC and NGP locations if those are disabled
if location.dlc and not self.options.enable_dlc: continue
if location.ngp and not self.options.enable_ngp: continue
# Replace non-randomized items with events that give the default item
event_item = (
self.create_item(location.default_item_name) if location.default_item_name

View File

@@ -39,16 +39,14 @@ randomized item and (optionally) enemy locations. You only need to do this once
To run _Dark Souls III_ in Archipelago mode:
1. Start Steam. **Do not run in offline mode.** Running Steam in offline mode will make certain
scripted invaders fail to spawn. Instead, change the game itself to offline mode on the menu
screen.
1. Start Steam. **Do not run Steam in offline mode.** Running Steam in offline mode will make certain
scripted invaders fail to spawn.
2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that
2. To prevent you from getting penalized, **make sure to set _Dark Souls III_ to offline mode in the game options.**
3. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that
you can use to interact with the Archipelago server.
3. Type `/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME}` into the command prompt, with the
appropriate values filled in. For example: `/connect archipelago.gg:24242 PlayerName`.
4. Start playing as normal. An "Archipelago connected" message will appear onscreen once you have
control of your character and the connection is established.

View File

@@ -89,11 +89,15 @@ class FF1Client(BizHawkClient):
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
try:
if (await bizhawk.get_memory_size(ctx.bizhawk_ctx, self.rom)) < rom_name_location + 0x0D:
return False # ROM is not large enough to be a Final Fantasy 1 ROM
# Check ROM name/patch version
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0])
rom_name = rom_name.decode("ascii")
if rom_name != "FINAL FANTASY":
return False # Not a Final Fantasy 1 ROM
except UnicodeDecodeError:
return False # rom_name returned invalid text
except bizhawk.RequestFailedError:
return False # Not able to get a response, say no for now

View File

@@ -154,7 +154,17 @@ class HKWeb(WebWorld):
["JoaoVictor-FA"]
)
tutorials = [setup_en, setup_pt_br]
setup_es = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Español",
"setup_es.md",
"setup/es",
["GreenMarco", "Panto UwUr"]
)
tutorials = [setup_en, setup_pt_br, setup_es]
game_info_languages = ["en", "es"]
bug_report_page = "https://github.com/Ijwu/Archipelago.HollowKnight/issues/new?assignees=&labels=bug%2C+needs+investigation&template=bug_report.md&title="
@@ -218,6 +228,11 @@ class HKWorld(World):
wp = self.options.WhitePalace
if wp <= WhitePalace.option_nopathofpain:
exclusions.update(path_of_pain_locations)
exclusions.update((
"Soul_Totem-Path_of_Pain",
"Lore_Tablet-Path_of_Pain_Entrance",
"Journal_Entry-Seal_of_Binding",
))
if wp <= WhitePalace.option_kingfragment:
exclusions.update(white_palace_checks)
if wp == WhitePalace.option_exclude:
@@ -226,6 +241,9 @@ class HKWorld(World):
# If charms are randomized, this will be junk-filled -- so transitions and events are not progression
exclusions.update(white_palace_transitions)
exclusions.update(white_palace_events)
exclusions.update(item_name_groups["PalaceJournal"])
exclusions.update(item_name_groups["PalaceLore"])
exclusions.update(item_name_groups["PalaceTotem"])
return exclusions
def create_regions(self):

View File

@@ -0,0 +1,25 @@
# Hollow Knight
## ¿Dónde está la página de opciones?
La [página de opciones de jugador para este juego](../player-options) contiene todas las opciones que necesitas para
configurar y exportar un archivo de configuración.
## ¿Qué se randomiza en este juego?
El randomizer cambia la ubicación de los objetos. Los objetos que se intercambian se eligen dentro de tu YAML.
Los costes de las tiendas son aleatorios. Los objetos que podrían ser aleatorios, pero no lo son, permanecerán sin
modificar en sus ubicaciones habituales. En particular, cuando los ítems con el PadreLarva y la Vidente están
parcialmente randomizados, los ítems randomizados se obtendrán de un cofre en la habitación, mientras que los ítems no
randomizados serán dados por el NPC de forma normal.
## ¿Qué objetos de Hollow Knight pueden aparecer en los mundos de otros jugadores?
Esto depende enteramente de tus opciones YAML. Algunos ejemplos son: amuletos, larvas, capullos de saviavida, geo, etc.
## ¿Qué aspecto tienen los objetos de otro mundo en Hollow Knight?
Cuando el jugador de Hollow Knight recoja un objeto de un lugar y sea un objeto para otro juego, aparecerá en la
pantalla de objetos recientes de ese jugador como un objeto enviado a otro jugador. Si el objeto es para otro jugador
de Hollow Knight entonces el sprite será el del sprite original del objeto. Si el objeto pertenece a un jugador que no
está jugando a Hollow Knight, el sprite será el logo del Archipiélago.

View File

@@ -0,0 +1,64 @@
# Hollow Knight Archipelago
## Software requerido
* Descarga y descomprime Lumafly Mod manager desde el [sitio web de Lumafly](https://themulhima.github.io/Lumafly/)
* Tener una copia legal de Hollow Knight.
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
## Instalación del mod de Archipelago con Lumafly
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes:
* Haz clic en uno de los enlaces de abajo para permitir Lumafly para instalar los mods. Lumafly pedirá
confirmación.
* [Archipiélago y dependencias solamente](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago)
* [Archipelago con rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/)
(incluye Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn,
y AdditionalMaps).
* Haz clic en el botón "Instalar" situado junto a la entrada del mod "Archipiélago". Si lo deseas, instala también
"Archipelago Map Mod" para utilizarlo como rastreador en el juego.
Si lo requieres (Y recomiendo hacerlo) busca e instala Archipelago Map Mod para usar un tracker in-game
3. Ejecuta el juego desde el apartado de inicio haciendo click en el botón Launch with Mods
## Que hago si Lumafly no encontro la ruta de instalación de mi juego?
1. Busca el directorio manualmente
* En Xbox Game pass:
1. Entra a la Xbox App y dirigete sobre el icono de Hollow Knight que esta a la izquierda.
2. Haz click en los 3 puntitos y elige el apartado Administrar
3. Dirigete al apartado Archivos Locales y haz click en Buscar
4. Abre en Hollow Knight, luego Content y copia la ruta de archivos que esta en la barra de navegación.
* En Steam:
1. Si instalaste Hollow Knight en algún otro disco que no sea el predeterminado, ya sabrás donde se encuentra
el juego, ve a esa carpeta, abrela y copia la ruta de archivos que se encuentra en la barra de navegación.
* En Windows, la ruta predeterminada suele ser:`C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* En linux/Steam Deck suele ser: ~/.local/share/Steam/steamapps/common/Hollow Knight
* En Mac suele ser: ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app
2. Ejecuta Lumafly como administrador y, cuando te pregunte por la ruta de instalación, pega la ruta que copeaste
anteriormente.
## Configuración de tu fichero YAML
### ¿Qué es un YAML y por qué necesito uno?
Un archivo YAML es la forma en la que proporcionas tus opciones de jugador a Archipelago.
Mira la [guía básica de configuración multiworld](/tutorial/Archipelago/setup/en) aquí en la web de Archipelago para
aprender más, (solo se encuentra en Inglés).
### ¿Dónde consigo un YAML?
Puedes usar la [página de opciones de juego para Hollow Knight](/games/Hollow%20Knight/player-options) aquí en la web
de Archipelago para generar un YAML usando una interfaz gráfica.
## Unete a una partida de Archipelago en Hollow Knight
1. Inicia el juego con los mods necesarios indicados anteriormente.
2. Crea una **nueva partida.**
3. Elige el modo **Archipelago** en la selección de modos de partida.
4. Introduce la configuración correcta para tu servidor de Archipelago.
5. Pulsa **Iniciar** para iniciar la partida. El juego se quedará con la pantalla en negro unos segundos mientras
coloca todos los objetos.
6. El juego debera comenzar y ya estaras dentro del servidor.
* Si estas esperando a que termine un contador/timer, procura presionar el boton Start cuando el contador/timer
termine.
* Otra manera es pausar el juego y esperar a que el contador/timer termine cuando ingreses a la partida.
## Consejos y otros comandos
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

View File

@@ -57,13 +57,8 @@ class ExtraLogic(DefaultOnToggle):
class Hylics2DeathLink(DeathLink):
"""
When you die, everyone dies. The reverse is also true.
Note that this also includes death by using the PERISH gesture.
Can be toggled via in-game console command "/deathlink".
"""
__doc__ = (DeathLink.__doc__ + "\n\n Note that this also includes death by using the PERISH gesture." +
"\n\n Can be toggled via in-game console command \"/deathlink\".")
@dataclass

View File

@@ -287,13 +287,13 @@ class BadStartingWeapons(Toggle):
class DonaldDeathLink(Toggle):
"""
If Donald is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone.
If Donald is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone who enabled death link.
"""
display_name = "Donald Death Link"
class GoofyDeathLink(Toggle):
"""
If Goofy is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone.
If Goofy is KO'ed, so is Sora. If Death Link is toggled on in your client, this will send a death to everyone who enabled death link.
"""
display_name = "Goofy Death Link"

View File

@@ -2,13 +2,15 @@ import binascii
import importlib.util
import importlib.machinery
import os
import pkgutil
import random
import pickle
import Utils
import settings
from collections import defaultdict
from typing import TYPE_CHECKING
from typing import Dict
from .romTables import ROMWithTables
from . import assembler
from . import mapgen
from . import patches
from .patches import overworld as _
from .patches import dungeon as _
@@ -57,27 +59,20 @@ from .patches import tradeSequence as _
from . import hints
from .patches import bank34
from .utils import formatText
from .roomEditor import RoomEditor, Object
from .patches.aesthetics import rgb_to_bin, bin_to_rgb
from .locations.keyLocation import KeyLocation
from BaseClasses import ItemClassification
from ..Locations import LinksAwakeningLocation
from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps
if TYPE_CHECKING:
from .. import LinksAwakeningWorld
from .. import Options
# Function to generate a final rom, this patches the rom with all required patches
def generateRom(args, world: "LinksAwakeningWorld"):
def generateRom(base_rom: bytes, args, patch_data: Dict):
random.seed(patch_data["seed"] + patch_data["player"])
multi_key = binascii.unhexlify(patch_data["multi_key"].encode())
item_list = pickle.loads(binascii.unhexlify(patch_data["item_list"].encode()))
options = patch_data["options"]
rom_patches = []
player_names = list(world.multiworld.player_name.values())
rom = ROMWithTables(args.input_filename, rom_patches)
rom.player_names = player_names
rom = ROMWithTables(base_rom, rom_patches)
rom.player_names = patch_data["other_player_names"]
pymods = []
if args.pymod:
for pymod in args.pymod:
@@ -88,10 +83,13 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for pymod in pymods:
pymod.prePatch(rom)
if world.ladxr_settings.gfxmod:
patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", world.ladxr_settings.gfxmod))
item_list = [item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)]
if options["gfxmod"]:
user_settings = settings.get_settings()
try:
gfx_mod_file = user_settings["ladx_options"]["gfx_mod_file"]
patches.aesthetics.gfxMod(rom, gfx_mod_file)
except FileNotFoundError:
pass # if user just doesnt provide gfxmod file, let patching continue
assembler.resetConsts()
assembler.const("INV_SIZE", 16)
@@ -121,7 +119,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
assembler.const("wLinkSpawnDelay", 0xDE13)
#assembler.const("HARDWARE_LINK", 1)
assembler.const("HARD_MODE", 1 if world.ladxr_settings.hardmode != "none" else 0)
assembler.const("HARD_MODE", 1 if options["hard_mode"] else 0)
patches.core.cleanup(rom)
patches.save.singleSaveSlot(rom)
@@ -135,7 +133,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.core.easyColorDungeonAccess(rom)
patches.owl.removeOwlEvents(rom)
patches.enemies.fixArmosKnightAsMiniboss(rom)
patches.bank3e.addBank3E(rom, world.multi_key, world.player, player_names)
patches.bank3e.addBank3E(rom, multi_key, patch_data["player"], patch_data["other_player_names"])
patches.bank3f.addBank3F(rom)
patches.bank34.addBank34(rom, item_list)
patches.core.removeGhost(rom)
@@ -144,19 +142,17 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.core.alwaysAllowSecretBook(rom)
patches.core.injectMainLoop(rom)
from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys
if world.options.shuffle_small_keys != ShuffleSmallKeys.option_original_dungeon or\
world.options.shuffle_nightmare_keys != ShuffleNightmareKeys.option_original_dungeon:
if options["shuffle_small_keys"] != Options.ShuffleSmallKeys.option_original_dungeon or\
options["shuffle_nightmare_keys"] != Options.ShuffleNightmareKeys.option_original_dungeon:
patches.inventory.advancedInventorySubscreen(rom)
patches.inventory.moreSlots(rom)
if world.ladxr_settings.witch:
patches.witch.updateWitch(rom)
# if ladxr_settings["witch"]:
patches.witch.updateWitch(rom)
patches.softlock.fixAll(rom)
if not world.ladxr_settings.rooster:
if not options["rooster"]:
patches.maptweaks.tweakMap(rom)
patches.maptweaks.tweakBirdKeyRoom(rom)
if world.ladxr_settings.overworld == "openmabe":
if options["overworld"] == Options.Overworld.option_open_mabe:
patches.maptweaks.openMabe(rom)
patches.chest.fixChests(rom)
patches.shop.fixShop(rom)
@@ -168,10 +164,10 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.tarin.updateTarin(rom)
patches.fishingMinigame.updateFinishingMinigame(rom)
patches.health.upgradeHealthContainers(rom)
if world.ladxr_settings.owlstatues in ("dungeon", "both"):
patches.owl.upgradeDungeonOwlStatues(rom)
if world.ladxr_settings.owlstatues in ("overworld", "both"):
patches.owl.upgradeOverworldOwlStatues(rom)
# if ladxr_settings["owlstatues"] in ("dungeon", "both"):
# patches.owl.upgradeDungeonOwlStatues(rom)
# if ladxr_settings["owlstatues"] in ("overworld", "both"):
# patches.owl.upgradeOverworldOwlStatues(rom)
patches.goldenLeaf.fixGoldenLeaf(rom)
patches.heartPiece.fixHeartPiece(rom)
patches.seashell.fixSeashell(rom)
@@ -180,143 +176,95 @@ def generateRom(args, world: "LinksAwakeningWorld"):
patches.songs.upgradeMarin(rom)
patches.songs.upgradeManbo(rom)
patches.songs.upgradeMamu(rom)
patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings)
patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal')
if world.ladxr_settings.bowwow != 'normal':
patches.bowwow.bowwowMapPatches(rom)
patches.tradeSequence.patchTradeSequence(rom, options)
patches.bowwow.fixBowwow(rom, everywhere=False)
# if ladxr_settings["bowwow"] != 'normal':
# patches.bowwow.bowwowMapPatches(rom)
patches.desert.desertAccess(rom)
if world.ladxr_settings.overworld == 'dungeondive':
patches.overworld.patchOverworldTilesets(rom)
patches.overworld.createDungeonOnlyOverworld(rom)
elif world.ladxr_settings.overworld == 'nodungeons':
patches.dungeon.patchNoDungeons(rom)
elif world.ladxr_settings.overworld == 'random':
patches.overworld.patchOverworldTilesets(rom)
mapgen.store_map(rom, world.ladxr_logic.world.map)
# if ladxr_settings["overworld"] == 'dungeondive':
# patches.overworld.patchOverworldTilesets(rom)
# patches.overworld.createDungeonOnlyOverworld(rom)
# elif ladxr_settings["overworld"] == 'nodungeons':
# patches.dungeon.patchNoDungeons(rom)
#elif world.ladxr_settings["overworld"] == 'random':
# patches.overworld.patchOverworldTilesets(rom)
# mapgen.store_map(rom, world.ladxr_logic.world.map)
#if settings.dungeon_items == 'keysy':
# patches.dungeon.removeKeyDoors(rom)
# patches.reduceRNG.slowdownThreeOfAKind(rom)
patches.reduceRNG.fixHorseHeads(rom)
patches.bomb.onlyDropBombsWhenHaveBombs(rom)
if world.options.music_change_condition == MusicChangeCondition.option_always:
if options["music_change_condition"] == Options.MusicChangeCondition.option_always:
patches.aesthetics.noSwordMusic(rom)
patches.aesthetics.reduceMessageLengths(rom, world.random)
patches.aesthetics.reduceMessageLengths(rom, random)
patches.aesthetics.allowColorDungeonSpritesEverywhere(rom)
if world.ladxr_settings.music == 'random':
patches.music.randomizeMusic(rom, world.random)
elif world.ladxr_settings.music == 'off':
if options["music"] == Options.Music.option_shuffled:
patches.music.randomizeMusic(rom, random)
elif options["music"] == Options.Music.option_off:
patches.music.noMusic(rom)
if world.ladxr_settings.noflash:
if options["no_flash"]:
patches.aesthetics.removeFlashingLights(rom)
if world.ladxr_settings.hardmode == "oracle":
if options["hard_mode"] == Options.HardMode.option_oracle:
patches.hardMode.oracleMode(rom)
elif world.ladxr_settings.hardmode == "hero":
elif options["hard_mode"] == Options.HardMode.option_hero:
patches.hardMode.heroMode(rom)
elif world.ladxr_settings.hardmode == "ohko":
elif options["hard_mode"] == Options.HardMode.option_ohko:
patches.hardMode.oneHitKO(rom)
if world.ladxr_settings.superweapons:
patches.weapons.patchSuperWeapons(rom)
if world.ladxr_settings.textmode == 'fast':
#if ladxr_settings["superweapons"]:
# patches.weapons.patchSuperWeapons(rom)
if options["text_mode"] == Options.TextMode.option_fast:
patches.aesthetics.fastText(rom)
if world.ladxr_settings.textmode == 'none':
patches.aesthetics.fastText(rom)
patches.aesthetics.noText(rom)
if not world.ladxr_settings.nagmessages:
#if ladxr_settings["textmode"] == 'none':
# patches.aesthetics.fastText(rom)
# patches.aesthetics.noText(rom)
if not options["nag_messages"]:
patches.aesthetics.removeNagMessages(rom)
if world.ladxr_settings.lowhpbeep == 'slow':
if options["low_hp_beep"] == Options.LowHpBeep.option_slow:
patches.aesthetics.slowLowHPBeep(rom)
if world.ladxr_settings.lowhpbeep == 'none':
if options["low_hp_beep"] == Options.LowHpBeep.option_none:
patches.aesthetics.removeLowHPBeep(rom)
if 0 <= int(world.ladxr_settings.linkspalette):
patches.aesthetics.forceLinksPalette(rom, int(world.ladxr_settings.linkspalette))
if 0 <= options["link_palette"]:
patches.aesthetics.forceLinksPalette(rom, options["link_palette"])
if args.romdebugmode:
# The default rom has this build in, just need to set a flag and we get this save.
rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around.
if world.ladxr_settings.steal == 'never':
rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
elif world.ladxr_settings.steal == 'always':
rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
#if ladxr_settings["steal"] == 'never':
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
#elif ladxr_settings["steal"] == 'always':
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
if world.ladxr_settings.hpmode == 'inverted':
patches.health.setStartHealth(rom, 9)
elif world.ladxr_settings.hpmode == '1':
patches.health.setStartHealth(rom, 1)
#if ladxr_settings["hpmode"] == 'inverted':
# patches.health.setStartHealth(rom, 9)
#elif ladxr_settings["hpmode"] == '1':
# patches.health.setStartHealth(rom, 1)
patches.inventory.songSelectAfterOcarinaSelect(rom)
if world.ladxr_settings.quickswap == 'a':
if options["quickswap"] == 'a':
patches.core.quickswap(rom, 1)
elif world.ladxr_settings.quickswap == 'b':
elif options["quickswap"] == 'b':
patches.core.quickswap(rom, 0)
patches.core.addBootsControls(rom, world.options.boots_controls)
patches.core.addBootsControls(rom, options["boots_controls"])
random.seed(patch_data["seed"] + patch_data["player"])
hints.addHints(rom, random, patch_data["hint_texts"])
world_setup = world.ladxr_logic.world_setup
JUNK_HINT = 0.33
RANDOM_HINT= 0.66
# USEFUL_HINT = 1.0
# TODO: filter events, filter unshuffled keys
all_items = world.multiworld.get_items()
our_items = [item for item in all_items
if item.player == world.player
and item.location
and item.code is not None
and item.location.show_in_spoiler]
our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification]
def gen_hint():
if not world.options.in_game_hints:
return 'Hints are disabled!'
chance = world.random.uniform(0, 1)
if chance < JUNK_HINT:
return None
elif chance < RANDOM_HINT:
location = world.random.choice(our_items).location
else: # USEFUL_HINT
location = world.random.choice(our_useful_items).location
if location.item.player == world.player:
name = "Your"
else:
name = f"{world.multiworld.player_name[location.item.player]}'s"
# filter out { and } since they cause issues with string.format later on
name = name.replace("{", "").replace("}", "")
if isinstance(location, LinksAwakeningLocation):
location_name = location.ladxr_item.metadata.name
else:
location_name = location.name
hint = f"{name} {location.item.name} is at {location_name}"
if location.player != world.player:
# filter out { and } since they cause issues with string.format later on
player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "")
hint += f" in {player_name}'s world"
# Cap hint size at 85
# Realistically we could go bigger but let's be safe instead
hint = hint[:85]
return hint
hints.addHints(rom, world.random, gen_hint)
if world_setup.goal == "raft":
if patch_data["world_setup"]["goal"] == "raft":
patches.goal.setRaftGoal(rom)
elif world_setup.goal in ("bingo", "bingo-full"):
patches.bingo.setBingoGoal(rom, world_setup.bingo_goals, world_setup.goal)
elif world_setup.goal == "seashells":
elif patch_data["world_setup"]["goal"] in ("bingo", "bingo-full"):
patches.bingo.setBingoGoal(rom, patch_data["world_setup"]["bingo_goals"], patch_data["world_setup"]["goal"])
elif patch_data["world_setup"]["goal"] == "seashells":
patches.goal.setSeashellGoal(rom, 20)
else:
patches.goal.setRequiredInstrumentCount(rom, world_setup.goal)
patches.goal.setRequiredInstrumentCount(rom, patch_data["world_setup"]["goal"])
# Patch the generated logic into the rom
patches.chest.setMultiChest(rom, world_setup.multichest)
if world.ladxr_settings.overworld not in {"dungeondive", "random"}:
patches.entrances.changeEntrances(rom, world_setup.entrance_mapping)
patches.chest.setMultiChest(rom, patch_data["world_setup"]["multichest"])
#if ladxr_settings["overworld"] not in {"dungeondive", "random"}:
patches.entrances.changeEntrances(rom, patch_data["world_setup"]["entrance_mapping"])
for spot in item_list:
if spot.item and spot.item.startswith("*"):
spot.item = spot.item[1:]
@@ -327,23 +275,22 @@ def generateRom(args, world: "LinksAwakeningWorld"):
# There are only 101 player name slots (99 + "The Server" + "another world"), so don't use more than that
mw = 100
spot.patch(rom, spot.item, multiworld=mw)
patches.enemies.changeBosses(rom, world_setup.boss_mapping)
patches.enemies.changeMiniBosses(rom, world_setup.miniboss_mapping)
patches.enemies.changeBosses(rom, patch_data["world_setup"]["boss_mapping"])
patches.enemies.changeMiniBosses(rom, patch_data["world_setup"]["miniboss_mapping"])
if not args.romdebugmode:
patches.core.addFrameCounter(rom, len(item_list))
patches.core.warpHome(rom) # Needs to be done after setting the start location.
patches.titleScreen.setRomInfo(rom, world.multi_key, world.multiworld.seed_name, world.ladxr_settings,
world.player_name, world.player)
if world.options.ap_title_screen:
patches.titleScreen.setRomInfo(rom, patch_data)
if options["ap_title_screen"]:
patches.titleScreen.setTitleGraphics(rom)
patches.endscreen.updateEndScreen(rom)
patches.aesthetics.updateSpriteData(rom)
if args.doubletrouble:
patches.enemies.doubleTrouble(rom)
if world.options.text_shuffle:
if options["text_shuffle"]:
excluded_ids = [
# Overworld owl statues
0x1B6, 0x1B7, 0x1B8, 0x1B9, 0x1BA, 0x1BB, 0x1BC, 0x1BD, 0x1BE, 0x22D,
@@ -388,6 +335,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
excluded_texts = [ rom.texts[excluded_id] for excluded_id in excluded_ids]
buckets = defaultdict(list)
# For each ROM bank, shuffle text within the bank
random.seed(patch_data["seed"] + patch_data["player"])
for n, data in enumerate(rom.texts._PointerTable__data):
# Don't muck up which text boxes are questions and which are statements
if type(data) != int and data and data != b'\xFF' and data not in excluded_texts:
@@ -395,20 +343,20 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for bucket in buckets.values():
# For each bucket, make a copy and shuffle
shuffled = bucket.copy()
world.random.shuffle(shuffled)
random.shuffle(shuffled)
# Then put new text in
for bucket_idx, (orig_idx, data) in enumerate(bucket):
rom.texts[shuffled[bucket_idx][0]] = data
if world.options.trendy_game != TrendyGame.option_normal:
if options["trendy_game"] != Options.TrendyGame.option_normal:
# TODO: if 0 or 4, 5, remove inaccurate conveyor tiles
room_editor = RoomEditor(rom, 0x2A0)
if world.options.trendy_game == TrendyGame.option_easy:
if options["trendy_game"] == Options.TrendyGame.option_easy:
# Set physics flag on all objects
for i in range(0, 6):
rom.banks[0x4][0x6F1E + i -0x4000] = 0x4
@@ -419,7 +367,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
# Add new conveyor to "push" yoshi (it's only a visual)
room_editor.objects.append(Object(5, 3, 0xD0))
if world.options.trendy_game >= TrendyGame.option_harder:
if options["trendy_game"] >= Options.TrendyGame.option_harder:
"""
Data_004_76A0::
db $FC, $00, $04, $00, $00
@@ -428,17 +376,18 @@ def generateRom(args, world: "LinksAwakeningWorld"):
db $00, $04, $00, $FC, $00
"""
speeds = {
TrendyGame.option_harder: (3, 8),
TrendyGame.option_hardest: (3, 8),
TrendyGame.option_impossible: (3, 16),
Options.TrendyGame.option_harder: (3, 8),
Options.TrendyGame.option_hardest: (3, 8),
Options.TrendyGame.option_impossible: (3, 16),
}
def speed():
return world.random.randint(*speeds[world.options.trendy_game])
random.seed(patch_data["seed"] + patch_data["player"])
return random.randint(*speeds[options["trendy_game"]])
rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A2-0x4000] = speed()
rom.banks[0x4][0x76A6-0x4000] = speed()
rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed()
if world.options.trendy_game >= TrendyGame.option_hardest:
if options["trendy_game"] >= Options.TrendyGame.option_hardest:
rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A3-0x4000] = speed()
rom.banks[0x4][0x76A5-0x4000] = speed()
@@ -462,11 +411,11 @@ def generateRom(args, world: "LinksAwakeningWorld"):
for channel in range(3):
color[channel] = color[channel] * 31 // 0xbc
if world.options.warps != Warps.option_vanilla:
patches.core.addWarpImprovements(rom, world.options.warps == Warps.option_improved_additional)
if options["warps"] != Options.Warps.option_vanilla:
patches.core.addWarpImprovements(rom, options["warps"] == Options.Warps.option_improved_additional)
palette = world.options.palette
if palette != Palette.option_normal:
palette = options["palette"]
if palette != Options.Palette.option_normal:
ranges = {
# Object palettes
# Overworld palettes
@@ -496,22 +445,22 @@ def generateRom(args, world: "LinksAwakeningWorld"):
r,g,b = bin_to_rgb(packed)
# 1 bit
if palette == Palette.option_1bit:
if palette == Options.Palette.option_1bit:
r &= 0b10000
g &= 0b10000
b &= 0b10000
# 2 bit
elif palette == Palette.option_1bit:
elif palette == Options.Palette.option_1bit:
r &= 0b11000
g &= 0b11000
b &= 0b11000
# Invert
elif palette == Palette.option_inverted:
elif palette == Options.Palette.option_inverted:
r = 31 - r
g = 31 - g
b = 31 - b
# Pink
elif palette == Palette.option_pink:
elif palette == Options.Palette.option_pink:
r = r // 2
r += 16
r = int(r)
@@ -520,7 +469,7 @@ def generateRom(args, world: "LinksAwakeningWorld"):
b += 16
b = int(b)
b = clamp(b, 0, 0x1F)
elif palette == Palette.option_greyscale:
elif palette == Options.Palette.option_greyscale:
# gray=int(0.299*r+0.587*g+0.114*b)
gray = (r + g + b) // 3
r = g = b = gray
@@ -531,10 +480,10 @@ def generateRom(args, world: "LinksAwakeningWorld"):
SEED_LOCATION = 0x0134
# Patch over the title
assert(len(world.multi_key) == 12)
rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(world.multi_key))
assert(len(multi_key) == 12)
rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(multi_key))
for pymod in pymods:
pymod.postPatch(rom)
return rom
return rom.save()

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