Compare commits

...

595 Commits

Author SHA1 Message Date
Duck
e49ba2ff6f Undertale: Use check_locations helper to avoid redundant sends (#5993) 2026-03-01 01:30:26 +01:00
Doug Hoskisson
61d5120f66 Core: use typing_extensions deprecated (#5989) 2026-03-01 00:14:33 +01:00
Chris W
ff5402c410 Fix(undertale): prevent massive bounce msg spam for position updates (#5990)
* fix(undertale): prevent massive bounce msg spam for position updates

* make sure player is removed on leaving / timing out

* do not check for tags: online, as bounce evaluation is or'd
2026-02-28 22:56:28 +00:00
black-sliver
fcccbfca65 MultiServer: don't keep multidata alive for race_mode (#5980) 2026-02-26 18:31:39 +00:00
black-sliver
2db5435474 CI: upgrade InnoSetup to 6.7.0 (#5979) 2026-02-26 10:34:23 +01:00
Aaron Wagener
eeb022fa0c The Messenger: minor maintenance (#5965) 2026-02-26 02:24:50 +01:00
Duck
b30b2ecb07 Return new state man (Vi's note: I have chosen not to change this title) (#5978) 2026-02-25 20:52:34 +01:00
DrAwesome4333
699ca8adf6 WebHost: Add CORS headers to API Endpoints (#5777) 2026-02-25 02:47:54 +01:00
Silvris
fefd790de6 ALTTP: remove world: MultiWorld and typing (#5974) 2026-02-24 18:43:42 +01:00
Fabian Dill
d83da1b818 WebHost: memory leak fixes (#5966) 2026-02-22 21:22:22 +01:00
Mysteryem
0de09cd794 Core: Better scaling explicit indirect conditions (#4582)
* Core: Better scaling explicit indirect conditions

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

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

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

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

* Update docs/adding games.md

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

---------

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

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

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

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

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

* attempt docs update

* move to verify resolution (keep?)

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

* Update advanced_settings_en.md

* Update Options.py

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

* Update Options.py

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

* unify random handling between range and set

* Update Options.py

* Update Options.py

* Update Options.py

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

* super is weird

* fix item/location

* remove groups from options

* unittest

* pep8

* Update Options.py

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

* Update Options.py

---------

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

* fix test

* oops fix

* update tests and typing

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

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

* docstring

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

---------

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

* Doc: add Linux running from source and build instructions

* Doc: fix name in running from source on Linux

* Update docs/running from source.md

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

---------

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

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

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

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

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

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

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

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

* Add detail_disconnected_regions parameter to visualize_regions

* Rename detail_disconnected_regions to detail_other_regions for consistency

* Add auto_assign_colors param to visualize_regions

* Make auto assignment of entrance colors deterministic

* Assume show_other_regions is true if detail_other_regions is true

* Remove unused random import

* whitespace adjustments

* Move overflow check to prevent potential infinite loop

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

* positive condition

---------

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

* Adding missing space to Options.py

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

---------

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

* implement most of the stuff

* add docs and fill out rest of the functionality

* add in explain functions

* dedupe items and add more docs

* pr feedback and optimization updates

* Self is not in typing on 3.10

* fix test

* Update docs/rule builder.md

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

* pr feedback

* love it when CI gives me different results than local

* add composition with bitwise and and or

* strongly typed option filtering

* skip resolving location parent region

* update docs

* update typing and add decorator

* add string explains

* move simplify code to world

* add wrapper rule

* I may need to abandon the generic typing

* missing space for faris

* fix hashing for resolved rules

* thank u typing extensions ilu

* remove bad cacheable check

* add decorator to assign hash and rule name

* more type crimes...

* region access rules are now cached

* break compatibility so new features work

* update docs

* replace decorators with __init_subclass__

* ok now the frozen dataclass is automatic

* one more type fix for the road

* small fixes and caching tests

* play nicer with tests

* ok actually fix the tests

* add item_mapping for faris

* add more state helpers as rules

* fix has from list rules

* fix can reach location caching and add set completion condition

* fix can reach entrance caching

* implement HasGroup and HasGroupUnique

* add more tests and fix some bugs

* Add name arg to create_entrance

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

* fix json dumping option filters

* restructure and test serialization

* add prop to disable caching

* switch to __call__ and revert access_rule changes

* update docs and make edge cases match

* ruff has lured me into a false sense of security

* also unused

* fix disabling caching

* move filter function to filter class

* add more docs

* tests for explain functions

* Update docs/rule builder.md

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

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

* chore: add empty webworld for test

* chore: optimize rule evaluations

* remove getattr from hot code paths

* testing new cache flags

* only clear cache for rules cached as false in collect

* update test for new behaviour

* do not have rules inherit from each other

* update docs on caching

* fix name of attribute

* make explain messages more colorful

* fix issue with combining rules with different options

* add convenience functions for filtering

* use an operator with higher precedence

* name conflicts less with optionfilter

* move simplify and instance caching code

* update docs

* kill resolve_rule

* kill true_rule and false_rule

* move helpers to base classes

* update docs

* I really should finish all of my

* fix test

* rename mixin

* fix typos

* refactor rule builder into folder for better imports

* update docs

* do not dupe collectionrule

* docs review feedback

* missed a file

* remove rule_caching_enabled from base World

* update docs on caching

* shuffle around some docs

* use option instead of option.value

* add in operator and more testing

* rm World = object

* test fixes

* move cache to logic mixin

* keep test rule builder world out of global registry

* todone

* call register_dependencies automatically

* move register deps call to call_single

* add filtered_resolution

* allow bool opts on filters

* fix serialization tests

* allow reverse operations

---------

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

---
updated-dependencies:
- dependency-name: protobuf
  dependency-version: 6.33.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-05 07:37:09 +01:00
ScorelessPine
3e0d42bf9e Core: Add SDL_MOUSE_FOCUS_CLICKTHROUGH=1 environment variable to kvui (#5804)
This variable should fix the 'double click' required when trying to interact with buttons on an unfocused window
2026-02-05 01:32:25 +01:00
James White
41e22dabda KH2: Add SuperBosses, Cups, AtlanticaToggle and SummonLevelLocationToggle to slot data (#5708)
* Add SuperBosses, Cups and AtlanticaToggle

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

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

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

* Casual Bounty: Move option to better spot

* Bugfix: Prevent possible .remove() crash

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

This reverts commit 3c929e00db.

* Bugfix: Typo in conditional

* Casual Bounties: Remove Scar, add MCP

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

* Casual Bounties: Add some clarity to the CasualBounty setting

* Docs: Update docs to reflect new CasualBounty setting

* KH2: Add bounty locations as location groups

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

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

* Update Connections to be compatible with python ver. 3.8

* Update inno_setup.iss

* Update inno_setup.iss

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

* Add Manifest + Minor Bugfixes

* Even further safeguards for Oho Oasis Temples

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

* Use `launch` instead of `launch_subprocess`

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

* Docs: add spanish language for MLSS

* Update worlds/mlss/docs/setup_es.md

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

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

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

---------

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

* Add back pymem for faster windows address searching.

* Replace other uses of pymem, parameterize executable names.

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

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

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

Made argument building for suprocessing goalc easier to read

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

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

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

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

* Putting PyMemoryEditor local. (#85)

* Putting PyMemoryEditor local

---------

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

* Fixing minor problems (#87)

* Refactor away circular launcher import.

* Push latest PyMemoryEditor scan utility (#91)

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

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

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

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

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

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

* Refactor LD_LIBRARY_PATH hack.

* Update worlds/jakanddaxter/agents/memory_reader.py

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

* Update worlds/jakanddaxter/agents/memory_reader.py

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

---------

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

* Add type parameter to new hint and fix whitespace errors

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

* OptionsCreator: run export on a separate thread

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

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

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

* OptionsCreator: show snack when save_filename fails

* OptionsCreator: disable window while exporting

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

* second pass

* tweak

* Update worlds/kh1/docs/kh1_en.md

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

* Update worlds/kh1/docs/kh1_en.md

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

* Update worlds/kh1/docs/kh1_en.md

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

* semicolon

---------

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

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

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

* Add in API Cacheing timers and related text blurb

* updated for merged edit to /static_tracker

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

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

* Unblame phar

* Too many words

* The better choice

* More rules

* Removed too much

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

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

* Make "official" error message show up for 3.14

* Add zip error handling

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

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

* reword

* apply Scipio's rewordings

* Update worlds/AutoWorld.py

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

* any

---------

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

* second draft

* fix indentation of bullet point wrapped lines

* move quote

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

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

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

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

* fix indentation of bullet point wrapped lines

* use %20 for spaces in links

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

* make APQuest a link

* also linkify 'run a local server'

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

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

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

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

* Update components.py

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

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

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

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

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

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

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

* Update Build APWorlds.run.xml

---------

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

* move it to a probably more appropriate place?

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

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

* Add docstring

* Remove unnecessary self.multiworld references

* Implement start_inventory_from_pool

* Convert logic to use LogicMixin

* Add location_exists_with_options function to deduplicate code

* Simplify starting tool creation

* Add Paint to supported games list

* Increment version to 0.4.1

* Update docs to include color selection features

* Fix world attribute definitions

* Fix linting errors

* De-duplicate lists of traps

* Move LogicMixin to __init__.py

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

* Fix OptionError formatting

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

* Increment version to 0.5.1

* Update CODEOWNERS

* Update documentation for 0.5.2 client changes

* Simplify region creation

* Add comments describing logic

* Remove unnecessary f-strings

* Remove unused import

* Refactor rules to location class

* Remove unnecessary self.multiworld references

* Update logic to correctly match client-side item caps

* Paint: Add manifest

---------

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

* explicitly recommend 32-bit ids

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

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

* Include full typename in output

* Update module access and address style comments

* Annotate variables

* multi-errors: Revert to while loop

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

* multi-errors: Updated style and comments

* Undo accidental index change

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

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

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

* Update .gitignore

* fix logic thing that one guy said

* fix platform being missing from chomp rock zone rules

* add json file

* added the wrong one

* remove extraneous lnk

* Update archipelago.json

---------

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

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

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

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

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

* Update archipelago.json

* Update archipelago.json

* Update archipelago.json

---------

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

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

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

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

* Revert wrong one

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

* doc update

* docs

* Fix Blast/Missile not clearing Reflect

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/__init__.py

remove unused import

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

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/modules/dungeon_er.py

make bool optional

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

* Update worlds/earthbound/modules/boss_shuffle.py

typing update

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

* Update worlds/earthbound/modules/boss_shuffle.py

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

* Filter events out of item name to id

* we call it a glorp

* Update worlds/earthbound/Regions.py

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

* Update worlds/earthbound/__init__.py

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

* Update worlds/earthbound/Items.py

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

* Update worlds/earthbound/Regions.py

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

* Fix missing optional import

* hint stuff

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

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

* Update ver

* Update docs

* Fix some money scripting issues

* Add argument to PSI fakeout attack

* Updated monkey caves shop description

* Remove closing markdown from doc

* Add new flavors

* Make flavors actually work

* Update platforms

* Fix common gear getting duplicated

* Split region initialization

* Condense checks for start inventory + some other junk

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

* wow that one was really bad :glorp:

* blah

* Fix cutoff option text

* switch start inventory concatenation to itertools

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

* Fix crash when generating with spoiler_only

* Fix happy-happy teleport not unlocking after beating carpainter

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

* Adjust some filler rarity

* Update world to use CreateHints and deprecate old method

* Fix epilogue skip being offset

* Rearrange a couple regions

* Fix tendapants getting deleted in battle

* update doc

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

* Fix mostly typing errors from silvris

* More type checks

* More typing

* Typema

* Type

* Fix enemy levels overwriting music

* Fix gihugic blunder

* Fix Lumine Hall enabling OSS

* del world

* Rel 4.2.7

* Remove some debug logs

* Fix vanilla bug with weird ambush detection

* Fix Starman Junior having an unscaled Freeze

* Change shop scaling

* Fix shops using the wrong thankful script

* Update some bosses in boss shuffle

* Loc group adjustment

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

* Get rid of some debug stuff

* Get boss shuffle running, dont merge

* Fix json and get boss shuffle no plando back up

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

* Fix belch shop using wrong logic

* Don't re-send goal status

* EBitem

* remove :

* idk if this is whatvi wanted

* All client messagesnow only send when relevant instead of constantly

* Patch up the rest of boss plando

* Fix Giygas being not excluded from enemizer

* Fix epilogue again

* adjust the sphere scaling name

* add the things

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

* Fix prefill properly

* Fix boss shuffle on vanilla slots.

* rename this, apparently

* Update archipelago.json

---------

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

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

* Apply cherry pick of 3076259

* Apply cherry pick of 6114a55

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

* Update Setup guide and info page

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

* Fix typos

* Update hard drive behavior description

* Hopefully fixed the mam from getting placed behind harddrives

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

* Add info/warning about save setup failure bug

* Add notes about dedicated server setup

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

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

* progrees i think

* Fixed some bugs

* Progress commmit incase my pc crashes

* progress i think as test passed

* I guess test pass, game still unbeatable tho

* its generating

* Some refactorings

* Fixed generation with different elevator tiers

* Remove debug statement

* Fix this link.

* Implemented abstract base classes + some fixes

* Implemented many many new options

* Yay more stuff

* Fixed renaming of filters

* Added 1.1 stuffs

* Added options groups and presets

* Fixes after variable renmame

* Added recipy groups for easyer hinting

* Implemented random Tier 0

* Updated slot_data

* Latest update for 1.1

* Applied cheaper building costs of assembler and foundry

* Implemented exploration cost in slot_data

* Fixed exposing option type

* Add goal time estimates

* Trap info

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

* Added iron ore to build hub

* Added Dark Matter Crystals

* Added Single Dark Matter Crystals

* Fixed typo in options preset

* Update setup directions and info

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

* Fixed incorrect description on the options

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

* Fixed options pickling error

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

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

* Fixed hub milestone item leaking to into wrong milestones

* Fixed unlock cost of geothermal generator

* Fixed itemlinks again

* Add troubleshooting note about hoverpacks

* Add starting inventory bundle delivery info

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

* Update __init__.py

Fixed mistake

* Cleaned docs to be better suited to get verified

* Update CODEOWNERS

Added Satisfactory

* Update README.md

Added Satisfactory

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

* Add terms entry for Archipelago mod

* Fixed generation of traps

* Added Robb as code owner

* Restore tests to original state

* Apply suggestions from code review

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

* Fix additional typos from code review

* Implemented fix for itterating enum flags on python 3.10

* Update en_Satisfactory.md

* Update setup_en.md

* Apply suggestions from code review

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

* more world > multiworld

* Clarify universal tracker behavior

* Fix typos

* Info on smart hinting system

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

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

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

* Apply suggestions from code review

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

* Wording

* Fix typo

* Update with changes from ToBeVerified branch

* Update note about gameplay options

* Update note about gameplay options

* Improved universal tracker handling

* Improved universal tracker + modernized code a bit

* Fixed bugs that where re-introduced

* Added Recipe: Excited Photonic Matter

* Removed python 3.9 workaround

* Fixed

* Apply suggestions from code review

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

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

* Updated option description

* Fixed typing

* More info on goal completion conditions

* More info on goal completion conditions (093fe38b6e)

* Apply suggestions from code review

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

* Implemented review results

* PEP8 stuff

* More PEP8

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

* speedups part1

* speedsups on part rules

* Fix formatting

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

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

* Fixed after merge

* Updated values + removed TODO

* PEPed up the code

* Small refactorings

* Updated name slot data to phase

* Fix hint creation

* Clarify wording of elevator goal

* Review result

* Fixed minor typo in option

* Update option time estimates

---------

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

* Update archipelago.json

---------

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

* adjust working dir

* use argv0 instead of appimage directly

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

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

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

* fix range with default random

* forgot module update

* handle namedrange default special

* handle option group of options we should not render

* Update OptionsCreator.py

* Update OptionsCreator.py

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

* fix Python 3.8 compatibility
`bisect_right`

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

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

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

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

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

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

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

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

* Update __init__.py

* update wording

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

* Add spaces

* Missed one

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

* Revert "Add them"

This reverts commit 82be86191f.

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

* Update Build APWorld.run.xml

---------

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

* Revision on priority

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

* update per vi feedback

* Trying a 2 sentence approach

* more details!

* Update options api.md

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

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

* slightly change it

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

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

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

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

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

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

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

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

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

* Update worlds/apquest/client/custom_views.py

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

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

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

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

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

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

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

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

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

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

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

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

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

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

* more words in options.py

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

* Less confusing wording about rules, hopefully?

* victory -> completion

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

* access rule performance

* Make all imports module-level in world.py

* formatting

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

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

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

* subclass instead of override

* I forgor to remove these

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

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

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

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

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

---------

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

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

* comments

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

The current flow of accessibility works like this:

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

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

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

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

* Reword

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

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

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

* Pycharm pls

* Violet pls

* Remove OptionsType

---------

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

* small grammar

* suggested fix

client is die (sadge)

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

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

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

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

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

* Update Generate.py

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

* change to an elif to avoid unnecessary nesting

---------

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

* Fix editorial issues in documentations

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

* added in non-common-client version clarification

* Update docs/network protocol.md

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

---------

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

* oop
2025-10-26 11:40:21 +01:00
LiquidCat64
3f139f2efb CV64: Fix Explosive DeathLink not working with Increase Shimmy Speed on #5523 2025-10-26 11:39:14 +01:00
Subsourian
41a62a1a9e SC2: added MindHawk to credits (#5549) 2025-10-26 08:54:17 +01:00
black-sliver
8837e617e4 WebHost, Multiple Worlds: fix images not showing in guides (#5576)
* Multiple: resize FR RA network commands screenshot

This is now more in line with the text (and the english version).

* Multiple: optimize EN RA network commands screenshot

The URL has changed, so it's a good time to optimize.

* WebHost, Worlds: fix retroarch images not showing

Implements a src/url replacement for relative paths.
Moves the RA screenshots to worlds/generic since they are shared.
Also now uses the FR version in ffmq.
Also fixes the formatting that resultet in the list breaking.
Also moves imports in render_markdown.

Guides now also properly render on Github.

* Factorio: optimize screenshots

The URL has changed, so it's a good time to optimize.

* Factorio: change guide screenshots to use relative URL

* Test: markdown: fix tests on Windows

We also can't use delete=True, delete_on_close=False
because that's not supported in Py3.11.

* Test: markdown: fix typo

I hope that's it now. *sigh*

* Landstalker: fix doc images not showing

Change to relative img urls.

* Landstalker: optimize doc PNGs

The URL has changed, so it's a good time to optimize.
2025-10-25 22:19:38 +02:00
black-sliver
2bf410f285 CI: update appimagetool to 2025-10-19 (#5578)
Beware: this has a bug, but it does not impact our CI.
2025-10-25 16:49:05 +00:00
NewSoupVi
04fe43d53a kvui: Fix audio being completely non-functional on Linux (#5588)
* kvui: Fix audio on Linux

* Update kvui.py
2025-10-25 15:34:59 +02:00
NewSoupVi
643f61e7f4 Core: Add a ruff.toml to the root directory (#5259)
* Add a ruff.toml to the root directory

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

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

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-25 00:19:42 +02:00
black-sliver
6b91ffecf1 WebHost: add missing docutils requirement ... (#5583)
... and update it to latest.
This is being used in WebHostLib.options directly.
A recent change bumped our required version, so this is actually a fix.
2025-10-24 00:55:10 +02:00
black-sliver
4f7f092b9b setup: check if the sign host is on a local network (#5501)
Could have a really bad timeout if it goes through default route and packet is dropped.
2025-10-24 00:54:27 +02:00
gaithern
df3c6b7980 KH1: Add specified encoding to file output from Client to avoid crashes with non ASCII characters (#5584)
* Fix Slot 2 Level Checks description

* Fix encoding issue
2025-10-23 23:01:02 +02:00
threeandthreee
19839399e5 LADX: stealing logic option (#3965)
* implement StealingInLogic option

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

sword to front for readability, but also can_farm condition was missing
2025-10-23 22:11:41 +02:00
CookieCat
4847be98d2 AHIT: Fix death link timestamps being incorrect (#5404) 2025-10-23 05:30:46 +02:00
black-sliver
3105320038 Test: check fields in world source manifest (#5558)
* Test: check game in world manifest

* Update test/general/test_world_manifest.py

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

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

according what was added to docs/apworld specification.md in PR 5509

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-10-22 01:52:44 +02:00
Silvris
e8c8b0dbc5 MM2: fix Proteus reading #5575 2025-10-21 19:10:39 +02:00
Duck
c199775c48 Pokemon RB: Fix likely unintended concatenation #5566 2025-10-20 21:48:17 +02:00
Duck
d2bf7fdaf7 AHiT: Fix likely unintended concatenation #5565 2025-10-20 21:47:49 +02:00
Duck
621ec274c3 Yugioh: Fix likely unintended concatenations (#5567)
* Fix likely unintended concatenations

* Yeah that makes sense why I thought there were more here
2025-10-20 21:47:16 +02:00
NewSoupVi
7cd73e2710 WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541)
* Fix webhost argparse with extra args

* accidentally added line

* WebHost: fix some typing

B64 url conversion is used in test/hosting,
so it felt appropriate to include this here.

* Test: Hosting: also test autogen

* Test: Hosting: simplify stop_* and leave a note about Windows compat

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

There are actually some differences with MP on Windows
that make it impossible to run this in CI.

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-20 17:40:32 +02:00
NewSoupVi
708df4d1e2 WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again) (#5573)
From Discord:

Well, flask-compress updated and now our 3.11 CI is failing

Why? They switched to a lib called backports.zstd
And 3.11 pkg_resources can't handle that.

pip finds it. But in our ModuleUpdate.py, we first pkg_resources.require packages, and this fails. I can't reproduce this locally yet, but in CI, it seems like even though backports.zstd is installed, it still fails on it and prompts installing it over and over in every unit test
Now what do we do :KEKW:
Black Sliver suggested pinning flask-compress for 3.11
But I would just like to point out that this means we can't unpin it until we drop 3.11
the real thing is we probably need to move away from pkg_resources? lol 
since it's been deprecated literally since the oldest version we support
2025-10-20 17:06:07 +02:00
black-sliver
914a534a3b WebHost: fix gen timeout/exception resource handling (#5540)
* WebHost: reset Generator proc title on error

* WebHost: fix shutting down autogen

This is still not perfect but solves some of the issues.

* WebHost: properly propagate JOB_TIME

* WebHost: handle autogen shutdown
2025-10-20 09:16:29 +02:00
NewSoupVi
11d18db452 Docs: APWorld documentation, make a distinction between APWorld and .apworld (#5509)
* APWorld docs: Make a distinction between APWorld and .apworld

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

* Update apworld specification.md
2025-10-19 09:05:34 +02:00
Nicholas Saylor
00acfe63d4 WebHost: Update publish_parts parameters (#5544)
old name is deprecated and new name allows both writer instance or alias/name.
2025-10-19 03:40:25 +02:00
Fafale
2ac9ab5337 Docs: add warning about BepInEx to HK translated setup guides (#5554)
* Update HK pt-br setup to add warning about BepInEx

* Update HK spanish setup guide to add warning about BepInEx
2025-10-19 03:36:35 +02:00
Benny D
2569c9e531 DLC Quest: Enable multi-classification items (#5552)
* implement prog trap item (thanks stardew)

* oops that's wrong

* okay this is right
2025-10-19 03:30:24 +02:00
Rosalie
946f227226 [FF1] Added Deep Dungeon locations to locations.json so they exist in the datapackage (#5392)
* Added DD locations to locations.json so they exist in the datapackage.

* Update worlds/ff1/data/locations.json

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

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-17 16:44:11 +02:00
Carter Hesterman
7ead8fdf49 Civ 6: Add era requirements for boosts and update boost prereqs (#5296)
* Resolve #5136

* Resolves #5210
2025-10-17 16:35:44 +02:00
Rosalie
f5f554cb3d [FF1] Client fix and improvement (#5390)
* FF1 Client fixes.

* Strip leading/trailing spaces from rom-stored player name.

* FF1R encodes the name as utf-8, as it happens.

* UTF-8 is four bytes per character, so we need 64 bytes for the name, not 16.
2025-10-17 16:34:10 +02:00
Alchav
3f2942c599 Super Mario Land 2: Logic fixes #5258
Co-authored-by: alchav <alchav@jalchavware.com>
2025-10-17 16:32:58 +02:00
Snarky
da519e7f73 SC2: fix incorrect preset option (#5551)
* SC2: fix incorrect preset option

* SC2: fix incorrect evil logic preset option

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
2025-10-17 16:30:05 +02:00
Duck
0718ada682 Core: Allow PlandoItems to be pickled (#5335)
* Add Options.PlandoItem

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

* Use deprecated decorator
2025-10-17 03:20:34 +02:00
Duck
f756919dd9 CI: Add worlds manifests to build action trigger (#5555)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-16 23:58:12 +02:00
Jérémie Bolduc
406b905dc8 Stardew Valley: Add archipelago.json (#5535)
* add apworld manifest

* add world version
2025-10-16 22:23:23 +02:00
JaredWeakStrike
91439e0fb0 KH2: Manifest eletric boogaloo (#5556)
* manifest file

* x y z for world version

* Update archipelago.json
2025-10-16 20:25:11 +02:00
RoobyRoo
03bd59bff6 Ocarina of Time: Create manifest (#5536)
* Create archipelago.json

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-16 11:48:04 +02:00
BlastSlimey
cf02e1a1aa shapez: Fix floating layers logic error #5263 2025-10-15 23:41:15 +02:00
JaredWeakStrike
f6d696ea62 KH2: Manifest File (#5553)
* manifest file

* x y z for world version
2025-10-15 23:40:21 +02:00
BadMagic100
123acdef23 Docs: warn HK users not to use BepInEx #5550 2025-10-15 13:35:00 +02:00
Nicholas Saylor
28c7a214dc Core: Use Better Practices Accessing Manifests (#5543)
* Close manifest files

* Name explicit encoding
2025-10-15 01:09:05 +02:00
NewSoupVi
bdae7cd42c MultiServer: Fix hinting multi-copy items bleeding found status (#5547)
* fix hinting multi-copy items bleeding found status

* reword
2025-10-14 20:44:01 +02:00
Silvris
fc404d0cf7 MM2: fix Heat Man always being invulnerable to Atomic Fire #5546 2025-10-14 09:27:41 +02:00
threeandthreee
5ce71db048 LADX: use start_inventory_from_pool (#4641) 2025-10-13 19:32:49 +02:00
NewSoupVi
aff98a5b78 CommonClient: Fix manually connecting to a url when the username or password has a space in it (#5528)
* CommonClient: Fix manually connecting to a url when the username or password has a space in it

* Update CommonClient.py

* Update CommonClient.py
2025-10-13 18:55:44 +02:00
Exempt-Medic
30cedb13f3 Core: Limit ItemLink Name to 16 Characters (#4318) 2025-10-13 18:32:53 +02:00
Seldom
0c1ecf7297 Terraria: Remove /apstart from docs (#5537) 2025-10-13 18:06:25 +02:00
black-sliver
5390561b58 MultiServer: Fix breaking weakrefs for SetNotify (#5539) 2025-10-12 21:46:16 +02:00
threeandthreee
bb457b0f73 SNI Client: fix that it isnt using host.yaml settings (#5533) 2025-10-11 11:16:47 +02:00
threeandthreee
6276ccf415 LADX: move client out of root (#4226)
* init

* Revert "init"

This reverts commit bba6b7a306.

* put it back but clean

* pass args

* windows stuff

* delete old exe

this seems like it?

* use marin icon in launcher

* use LauncherComponents.launch
2025-10-10 17:56:15 +02:00
Mysteryem
d3588a057c Tests: gc.freeze() by default in the test\benchmark\locations.py (#5055)
Without `gc.freeze()` and `gc.unfreeze()` afterward, the `gc.collect()`
call within each benchmark often takes much longer than all 100_000
iterations of the location access rule, making it difficult to benchmark
all but the slowest of access rules.

This change enables using `gc.freeze()` by default.
2025-10-10 17:19:52 +02:00
Katelyn Gigante
30ce74d6d5 core: Add host.yaml setting to make !countdown configurable (#5465)
* core:  Add host.yaml setting to make !countdown configurable

* Store /option changes to countdown_mode in save file

* Wording changes in host.yaml

* Use .get

* Fix validation for /option command
2025-10-10 15:02:56 +02:00
NewSoupVi
ff59b86335 Docs: More apworld manifest documentation (#5477)
* Expand apworld specification manifest part

* clarity

* expand example

* clarify

* correct

* Correct

* elaborate on what version is

* Add where the apworlds are output

* authors & update versions

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md
2025-10-09 20:23:21 +02:00
NewSoupVi
e355d20063 WebHost: Don't show e.__cause__ on the generation error page #5521 2025-10-08 07:22:14 +02:00
Fabian Dill
28ea2444a4 kvui: re-enable settings menu (#4823) 2025-10-08 06:34:00 +02:00
black-sliver
e907980ff0 MultiServer: slight optimizations (#5527)
* Core: optimize MultiServer.Client

* Core: optimize websocket compression settings
2025-10-08 02:22:34 +02:00
Snarky
5a933a160a SC2: Add option presets (#5436)
* SC2: Add option presets

* SC2: Address reviews

* SC2: Fix import

* SC2: Update key mode

* SC2: Update renamed option

* sc2: PR comment; switching from __dataclass_fields__ to dataclasses.fields()

* sc2: Changing quote style to match AP standard

* sc2: PR comments; Switching to Starcraft2.type_hints

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
Co-authored-by: MatthewMarinets <matthew.marinets@gmail.com>
2025-10-07 17:25:08 +02:00
Duck
c7978bcc12 Docs: Add info about custom worlds (#5510)
* Cleaning up (#4)

Cleanup

* Added new paragraph for new games

* Update worlds/generic/docs/setup_en.md

Proofier-comitting

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

* Added a mention in the header of the games page to refer to this guide if needed.

* Small tweaks

* Added mention regarding alternate version of worlds

* Update WebHostLib/templates/supportedGames.html

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

* Update worlds/generic/docs/setup_en.md

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

* Edits for comments

* Slight alternate versions rewording

* Edit subheadings

* Adjust link text

* Replace alternate versions section and reword first

---------

Co-authored-by: Danaël V <104455676+ReverM@users.noreply.github.com>
Co-authored-by: Rever <danael.villeneuve@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-10-06 04:48:42 +02:00
Duck
5c7a84748b WebHost: Handle blank values for OptionCounters #5517 2025-10-06 04:38:38 +02:00
Duck
8dc9719b99 Core: Cleanup unneeded use of Version/tuplize_version (#5519)
* Remove weird version uses

* Restore version var

* Unrestore version var
2025-10-06 01:56:09 +02:00
black-sliver
60617c682e WebHost: fix log fetching extra characters when there is non-ascii (#5515) 2025-10-05 21:05:52 +02:00
massimilianodelliubaldini
fd879408f3 WebHost: Improve user friendliness of generation failure webpage (#4964)
* Improve user friendliness of generation failure webpage.

* Add details to other render for seedError.html.

* Refactor css to avoid !important tags.

* Update WebHostLib/static/styles/themes/ocean-island.css

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

* Update WebHostLib/generate.py

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

* use f words

* small refactor

* Update WebHostLib/generate.py

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

* Fix whitespace.

* Update one new use of seedError template for pickling errors.

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-05 15:38:57 +02:00
Mysteryem
8decde0370 Core: Don't waste swaps by swapping two copies of the same item (#5516)
There is a limit to the number of times an item can be swapped to
prevent swapping going on potentially forever. Swapping an item with a
copy of itself is assumed to be a pointless swap, and was wasting
possible swaps in cases where there were multiple copies of an item
being placed.

This swapping behaviour was noticed from debugging solo LADX generations
that was wasting swaps by swapping copies of the same item.

This patch adds a check that if the placed_item and item_to_place are
equal, then the location is skipped and no attempt to swap is made.

If worlds do intend to have seemingly equal items to actually have
different logical behaviour, those worlds should override __eq__ on
their Item subclasses so that the item instances are not considered
equal.

Generally, fill_restrictive should only be used with progression items,
so it is assumed that swapping won't have to deal with multiple copies
of an item where some copies are progression and some are not. This is
relevant because Item.__eq__ only compares .name and .player.
2025-10-05 15:07:12 +02:00
PoryGone
adb5a7d632 SA2B, DKC3, SMW, Celeste 64, Celeste (Open World): Manifest manifests 2025-10-05 06:47:01 +02:00
Jérémie Bolduc
f07fea2771 CommonClient: Move command marker to last_autofillable_command (#4907)
* handle autocomplete command when press question

* fix test

* add docstring to get_input_text_from_response

* fix line lenght
2025-10-05 05:39:30 +02:00
James White
a2460b7fe7 Pokemon RB: Add client tracking for tracker relevant events (#5495)
* Pokemon RB: Add client tracking for tracker relevant events

* Pokemon RB: Use list for tracker events

* Pokemon RB: Use correct bill event

* Pokemon RB: Add champion event tracking
2025-10-05 05:33:52 +02:00
Katelyn Gigante
f8f30f41b7 Launcher: Newly installed custom worlds are not relative #4989
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-05 05:30:52 +02:00
Benny D
60070c2f1e PyCharm: add a run config for the new apworld builder workflow (#5489)
* add Build APWorld PyCharm run config

* change casing of the argument

* Update Build APWorld.run.xml

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-05 05:13:04 +02:00
Louis M
3eb25a59dc Aquaria: Updating documentation to add latest clients informations (#5438)
* Updating Aquaria documentation to add latest clients informations

* Typo in the permission explanation
2025-10-05 05:08:34 +02:00
Branden Wood
1cbc5d6649 Short Hike: improve setup guide docs #5470 2025-10-05 05:08:15 +02:00
DJ-lennart
bdef410eb2 Civilization VI: Update for the setup instructions #5286 2025-10-05 05:07:11 +02:00
Duck
ec9145e61d Region: Use Mapping type for adding locations/exits #5354 2025-10-05 05:04:02 +02:00
Duck
a547c8dd7d Core: Add location count field for world to spoiler log (#5440)
* Add location count

* Only count non-events

* Add total count
2025-10-05 05:02:26 +02:00
PinkSwitch
7996fd8d19 Core: Update start inventory description to mention item quantities (#5460)
* SNIClient: new SnesReader interface

* fix Python 3.8 compatibility
`bisect_right`

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

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

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

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

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

* Change descriptions

* Fix sni client?

---------

Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-05 05:01:56 +02:00
Scipio Wright
7a652518a3 [Website docs] Update wording of "adding a game to archipelago" section 2025-10-05 04:59:52 +02:00
Duck
ae4426af08 Core: Pad version string in world printout #5511 2025-10-05 04:46:26 +02:00
black-sliver
91e97b68d4 Webhost: eagerly free resources in customserver (#5512)
* Unref some locals that would live long for no reason.
* Limit scope of db_session in init_save.
2025-10-05 03:49:56 +02:00
NewSoupVi
6a08064a52 Core: Assert that if an apworld manifest file exists, it has a game field (#5478)
* Assert that if an apworld manifest file exists, it has a game field

* god damnit

* Update worlds/LauncherComponents.py

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

* Update setup.py

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

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-10-04 03:04:23 +02:00
Kaito Sinclaire
83cfb803a7 SMZ3: Fix forced fill behaviors (GT junk fill, initial Super/PB front fill) (#5361)
* SMZ3: Make GT fill behave like upstream SMZ3 multiworld GT fill

This means: All items local, 50% guaranteed filler, followed by possible
useful items, never progression.

* Fix item links

* SMZ3: Ensure in all cases, we remove the right item from the pool

Previously front fill would cause erratic errors on frozen, with the
cause immediately revealed by, on source, tripping the assert that was
added in #5109

* SMZ3: Truly, *properly* fix GT junk fill

After hours of diving deep into the upstream SMZ3 randomizer, it finally
behaves identically to how it does there
2025-10-03 02:05:29 +02:00
qwint
6d7abb3780 Webhost: Ignore Invalid Worlds in Webhost (#5433)
* filter world types at top of webhost so worlds that aren't loadable in webhost are "uninstalled"

* mark invalid worlds, show error if any, then filter to exclude them
2025-10-03 01:56:11 +02:00
Silvris
50f6cf04f6 Core: "Build APWorlds" cleanup (#5507)
* allow filtered build, subprocess

* component description

* correct name

* move back to running directly
2025-10-02 09:36:33 +02:00
qwint
b162095f89 Launcher: Rework apworld install popup #5508 2025-10-01 21:54:41 +02:00
Silvris
33b485c0c3 Core: expose world version to world classes and yaml (#5484)
* support version on new manifest

* apply world version from manifest

* Update Generate.py

* docs

* reduce mm2 version again

* wrong version

* validate game in world_types

* Update Generate.py

* let unknown game fall through to later exception

* hide real world version behind property

* named tuple is immutable

* write minimum world version to template yaml, fix gen edge cases

* punctuation

* check for world version in autoworldregister

* missed one

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-01 02:47:08 +02:00
Ziktofel
4893ac3e51 SC2: Fix Terran global upgrades present even if no Terran build missions are rolled (#5452)
* Fix Terran global upgrades present even if no Terran build missions are rolled

* Code cleanup
2025-10-01 02:40:30 +02:00
Phaneros
76b0197462 SC2: any_unit and item parent bugfixes (#5480)
* sc2: Fixing a Reaver item being classified as a scout item

* sc2: any_units now requires any AA in the first 5 units
* Fixing Shoot the Messenger not requiring AA in a hard rule
* Fixing any_unit zerg still allowing unupgraded mercs

* sc2: Fixed an issue where terran was requiring zerg anti-air in any_units
2025-09-30 22:18:42 +02:00
Scipio Wright
6a63de2f0f TUNIC: Fuse and Bell Shuffle (#5420)
* Making the fix better (thanks medic)

* Make it actually return false if it gets to the backup lists and fails them

* Fix stuff after merge

* Add outlet regions, create new regions as needed for them

* Put together part of decoupled and direction pairs

* make direction pairs work

* Make decoupled work

* Make fixed shop work again

* Fix a few minor bugs

* Fix a few minor bugs

* Fix plando

* god i love programming

* Reorder portal list

* Update portal sorter for variable shops

* Add missing parameter

* Some cleanup of prints and functions

* Fix typo

* it's aliiiiiive

* Make seed groups not sync decoupled

* Add test with full-shop plando

* Fix bug with vanilla portals

* Handle plando connections and direction pair errors

* Update plando checking for decoupled

* Fix typo

* Fix exception text to be shorter

* Add some more comments

* Add todo note

* Remove unused safety thing

* Remove extra plando connections definition in options

* Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log

* Fix weird edge case that is technically user error

* Add note to fixed shop

* Fix parsing shop names in UT

* Remove debug print

* Actually make UT work

* multiworld. to world.

* Fix typo from merge

* Make it so the shops show up in the entrance hints

* Fix bug in ladder storage rules

* Remove blank line

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/rules.py
#	worlds/tunic/test/test_access.py

* Fix issues after merge

* Update plando connections stuff in docs

* Make early bushes only contain grass

* Fix library mistake

* Backport changes to grass rando (#20)

* Backport changes to grass rando

* add_rule instead of set_rule for the special cases, add special cases for back of swamp laurels area cause I should've made a new region for the swamp upper entrance

* Remove item name group for grass

* Update grass rando option descriptions

- Also ignore grass fill for single player games

* Ignore grass fill option for solo rando

* Update er_rules.py

* Fix pre fill issue

* Remove duplicate option

* Add excluded grass locations back

* Hide grass fill option from simple ui options page

* Check for start with sword before setting grass rules

* Update worlds/tunic/options.py

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

* has_stick -> has_melee

* has_stick -> has_melee

* Add a failsafe for direction pairing

* Fix playthrough crash bug

* Remove init from logicmixin

* Updates per code review (thanks hesto)

* has_stick to has_melee in newer update

* has_stick to has_melee in newer update

* Exclude grass from get_filler_item_name

- non-grass rando games were accidentally seeing grass items get shuffled in as filler, which is funny but probably shouldn't happen

* Update worlds/tunic/__init__.py

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

* Apply suggestions from code review

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

* change the rest of grass_fill to local_fill

* Filter out grass from filler_items

* remove -> discard

* Update worlds/tunic/__init__.py

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

* Starting out

* Rules for breakable regions

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/combat_logic.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py

* Cleanup more stuff after merge

* Revert "Cleanup more stuff after merge"

This reverts commit a6ee9a93da.

* Revert "# Conflicts:"

This reverts commit c74ccd74a4.

* Cleanup more stuff after merge

* change has_stick to has_melee

* Update grass list with combat logic regions

* More fixes from combat logic merge

* Fix some dumb stuff (#21)

* Reorganize pre fill for grass

* make the rest of it work, it's pr ready, boom

* Make it work in not pot shuffle

* Merge grass rando

* multiworld -> world get_location, use has_any

* Swap out region for West Garden Before Terry grass

* Adjust west garden rules to add west combat region

* Adjust grass regions for south checkpoint grass

* Adjust grass regions for after terry grass

* Adjust grass regions for west combat grass

* Adjust grass regions for dagger house grass

* Adjust grass regions for south checkpoint grass, adjust regions and rules for some related locations

* Finish the remainder of the west garden grass, reformat ruined atoll a little

* More hex quest updates

- Implement page ability shuffle for hex quest
- Fix keys behind bosses if hex goal is less than 3
- Added check to fix conflicting hex quest options
- Add option to slot data

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Merge in hex hunt

* Merge in changes

* Clean up imports

* Add ability type to UT stuff

* merge it all

* Make local fill work across pot and grass (to be adjusted later)

* Make separate pools for the grass and non-grass fills

* Fix id overlap

* Update option description

* Fix default

* Reorder localfill option desc

* Load the purgatory ones in

* Adjustments after merge

* Fully remove logicrules

* Fix UT support with fixed shop option

* Add breakable shuffle to the ut stuff

* Make it load in a specific number of locations

* Add Silent's spoiler log ability thing

* Fix for groups

* Fix for groups

* Fix typo

* Fix hex quest UT support

* Use .get

* UT fixes, classification fixes

* Rename some locations

* Adjust guard house names

* Adjust guard house names

* Rework create_item

* Fix for plando connections

* Rename, add new breakables

* Rename more stuff

* Time to rename them again

* Fix issue with fixed shop + decoupled

* Put in an exception to catch that error in the future

* Update create_item to match main

* Update spoiler log lines for hex abilities

* Burn the signs down

* Bring over the combat logic fix

* Merge in combat logic fix

* Silly static method thing

* Move a few areas to before well instead of east forest

* Add an all_random hidden option for dev stuff

* Port over changes from main

* Fix west courtyard pot regions

* Remove debug prints

* Fix fortress courtyard and beneath the fortress loc groups again

* Add exception handling to deal with duplicate apworlds

* Fix typo

* More missing loc group conversions

* Initial fuse shuffle stuff

* Fix gun missing from combat_items, add new for combat logic cache, very slight refactor of check_combat_reqs to let it do the changeover in a less complicated fashion, fix area being a boss area rather than non-boss area for a check

* Add fuse shuffle logic

* reorder atoll statue rule

* Update traversal reqs

* Remove fuse shuffle from temple door

* Combine rules and option checking

* Add bell shuffle; fix fuse location groups

* Fix portal rules not requiring prayer

* Merge the grass laurels exit grass PR

* Merge in fortress bridge PR

* Do a little clean up

* Fix a regression

* Update after merge

* Some more stuff

* More Silent changes

* Update more info section in game info page

* Fix rules for atoll and swamp fuses

* Precollect cathedral fuse in ER

* actually just make the fuse useful instead of progression

* Add it to the swamp and cath rules too

* Fix cath fuse name

* Minor fixes and edits

* Some UT stuff

* Fix a couple more groups

* Move a bunch of UT stuff to its own file

* Fix up a couple UT things

* Couple minor ER fixes

* Formatting change

* UT poptracker stuff enabled since it's optional in one of the releases

* Add author string to world class

* Adjust local fill option name

* Update ut_stuff to match the PR

* Add exception handling for UT with old apworld

* Fix missing tracker_world

* Remove extra entrance from cath main -> elevator

Entry <-> Elev exists,
Entry <-> Main exists
So no connection is needed between Main and Elev

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Update for breakables poptracker

* Backup and warnings instead

* Update typing

* Delete old regions and rules, move stuff to logic_helpers and constants

* Delete now much less useful tests

* Fix breakables map tracking

* Add more comments to init

* Add todo to grass.py

* Fix up tests

* Fully remove fixed_shop

* Finish hard deprecating FixedShop

* Fix zig skip showing up in decoupled fixed shop

* Make local_fill show up on the website

* Merge with main

* Fixes after merge

* More fixes after merge

* oh right that's why it was there, circular imports

* Swap {} to ()

* Add fuse and bell shuffle to seed groups since they're logically significant for entrance pairing

---------

Co-authored-by: silent-destroyer <osilentdestroyer@gmail.com>
Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-09-30 21:39:41 +02:00
NewSoupVi
e6fb7d9c6a Core: Add an "options" arg to setup_multiworld so that non-default options can be set in it #5414 2025-09-30 20:23:33 +02:00
Fabian Dill
0882c0fa97 Core: only store persistent changes if there are changes (#5311) 2025-09-30 19:27:43 +02:00
threeandthreee
f26fcc0eda LADX: use generic slot name for slots 101+ (#5208)
* init

* we already had the generic name, just use it

* cap hints at 101

* nevermind, the name is just baked in here
2025-09-30 18:47:17 +02:00
Goblin God
50c9d056c9 KH1: Fix a small error in option descriptions #5445 2025-09-30 18:40:20 +02:00
threeandthreee
5cec3f45f5 LADX: reorganize options page (#4851)
* init

* merge upstream/main

* improve option tooltips, clean up file a bit

* ladx feels like more of an ocean game

* one more

* more cleanup

* some reorg

* Apply suggestions from code review

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

* clean up accidental newlines

* rewording

* dont do the ohko alias

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-09-30 18:39:53 +02:00
Katelyn Gigante
448f214cdb core: Option to skip "unused" item links (#4608)
* core:  Option to skip "unused" item links

* Update worlds/generic/docs/advanced_settings_en.md

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

* Update BaseClasses.py

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-09-30 18:39:04 +02:00
Phaneros
49f2d30587 Sc2: [performance] change default options (#5424)
* sc2: Changing default campaign options to something more performative and desirable for new players

* sc2: Fixing broken test that was missed in roundup

* SC2: Update tests for new defaults

* SC2: Fix incomplete test

* sc2: Updating description for enabled campaigns to mention which are free to play

* sc2: PR comments; Updating additional unit tests that were affected by a default change

* sc2: Adding a comment to the Enabled Campaigns option to list all the valid campaign names

* sc2: Adding quotes wrapping sample values in enabled_campaigns comment to aid copy-pasting

---------

Co-authored-by: Salzkorn <salzkitty@gmail.com>
2025-09-30 18:36:41 +02:00
Ziktofel
897d5ab089 SC2: Fix Conviction logic for Grant Story Tech (#5419)
* Fix Conviction logic for Grant Story Tech

- Kinetic Blast and Crushing Grip is available for the mission if story tech is granted

* Review updates
2025-09-30 18:35:26 +02:00
Phaneros
92ff0ddba8 SC2: Launcher bugfixes after content merge (#5409)
* sc2: Fixing Launcher.py launch not properly handling command-line arguments

* sc2: Fixing some old option names in webhost

* sc2: Switching to common client url parameter handling
2025-09-30 18:34:26 +02:00
Duck
1d2ad1f9c9 Docs: More type annotation changes (#5301)
* Update docs annotations

* Update settings recommendation

* Remove Dict in comment
2025-09-30 18:32:50 +02:00
threeandthreee
516ebc53ce LADX: fix local lvl 2 sword on the beach turning into a lvl 0 shield #5334
e3e49b16d6
2025-09-30 18:31:49 +02:00
Silvris
a30b43821f KDL3, MM2: set goal condition before generate basic (#5382)
* move goal kdl3

* mm2

* missed the singular important line
2025-09-30 18:30:26 +02:00
gaithern
d9955d624b KH1: Fix Slot 2 Level Checks description #5451 2025-09-30 05:10:29 +02:00
NewSoupVi
5345937966 The Witness: Remove two things from slot_data that nothing uses anymore #5502 2025-09-30 04:45:59 +02:00
massimilianodelliubaldini
580370c3a0 Jak and Daxter: close Power Cell loophole in trades test #5493 2025-09-30 04:43:59 +02:00
Scipio Wright
c30a5b206e Noita: Add archipelago.json (#5483)
* Add archipelago.json

* Add authors

* make it a list
2025-09-30 04:12:19 +02:00
Bryce Wilson
053f876e84 Pokemon Emerald: Add manifest (#5487) 2025-09-30 04:10:45 +02:00
massimilianodelliubaldini
ab2097960d Jak and Daxter: Add manifest #5492 2025-09-30 03:54:32 +02:00
Justus Lind
2f23dc72f9 Muse Dash: Update song list to Legendary Voyage, Mystic Treasure. Add manifest. (#5498)
* Legendary Voyage, Mystic Treasure Update

* Add manifest

* Correct Manifest version.

* Fix file encoding
2025-09-30 03:54:14 +02:00
Felix R
f9083d9307 bumpstik: Create manifest (#5496) 2025-09-30 03:53:47 +02:00
Felix R
25baa57850 meritous: Create manifest (#5497) 2025-09-30 03:53:31 +02:00
Scipio Wright
47b2242c3c TUNIC: Add archipelago.json (#5482)
* add archipelago.json

* newline

* Add authors

* Make it a list
2025-09-30 03:53:10 +02:00
Fabian Dill
6099869c59 Core: new cx_freeze (#5316) 2025-09-30 01:52:12 +02:00
palex00
1d861d1d06 Pokémon RB: Update Slot Data (#5494) 2025-09-28 23:18:06 +02:00
Bryce Wilson
d1624679ee Pokemon Emerald: Set all abilities to Cacophony if all are blacklisted (#5488) 2025-09-28 21:39:18 +02:00
Bryce Wilson
12998bf6f4 Pokemon Emerald: Fix missing fanfare address (#5490) 2025-09-27 16:54:03 +02:00
NewSoupVi
24394561bd Core: Bump Container Version to 7, and make APWorldContainer use 7 as the compatible_version #5479 2025-09-25 05:10:23 +02:00
Fabian Dill
4ae87edf37 Core: apworld manifest launcher component (#5340)
adds a launcher component that builds all apworlds on top of #4516
---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-09-24 23:25:46 +02:00
Etsuna
4525bae879 Webhost: add total player location counts to tracker API (#5441) 2025-09-24 20:08:14 +02:00
Fabian Dill
dc270303a9 Core: improve formatting on /help command (#5381) 2025-09-24 17:33:44 +02:00
Fabian Dill
a99da85a22 Core: APWorld manifest (#4516)
Adds support for a manifest file (archipelago.json) inside an .apworld file. It tells AP the game, minimum core version (optional field), maximum core version (optional field), its own version (used to determine which file to prefer to load only currently)
The file itself is marked as required starting with core 0.7.0, prior, just a warning is printed, with error trace.

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-09-24 02:39:19 +02:00
CaitSith2
e256abfdfb Factorio: Allow to reconnect a timed out RCON client connection. (#5421) 2025-09-22 03:07:33 +02:00
Fabian Dill
fb9011da63 WebHost: revamp /api/*tracker/ (#5388) 2025-09-22 00:25:12 +02:00
Fabian Dill
68187ba25f WebHost: remove team argument from tracker arguments where it's irrelevant (#5272) 2025-09-22 00:17:10 +02:00
Fabian Dill
6c45c8d606 Core: make countdown a "admin" only command (#5463) 2025-09-21 19:23:29 +02:00
threeandthreee
9e96cece56 LADX: Fix quickswap #5399 2025-09-21 18:59:40 +02:00
agilbert1412
1bd44e1e35 Stardew Valley: Fixed Traveling merchant flaky test (#5434)
* - Made the traveling cart test not be flaky due to worlds caching

# Conflicts:
#	worlds/stardew_valley/rules.py

* - Made the traveling merchant test less flaky

# Conflicts:
#	worlds/stardew_valley/test/rules/TestTravelingMerchant.py
2025-09-21 18:58:15 +02:00
Phaneros
7badc3e745 SC2: Logic bugfixes (#5461)
* sc2: Fixing always-true rules in locations.py; fixed two over-constrained rules that put vanilla out-of-logic

* sc2: Minor min2() optimization in rules.py

* sc2: Fixing a Shatter the Sky logic bug where w/a upgrades were checked too many times and for the wrong units
2025-09-21 18:54:22 +02:00
Scipio Wright
3af1e92813 TUNIC: Update name of a chest in the UT poptracker map integration #5462 2025-09-21 18:47:11 +02:00
Fabian Dill
73718bbd61 Core: make APContainer seek archipelago.json (#5261) 2025-09-19 03:52:31 +02:00
Sunny Bat
8f2b4a961f Raft: Add Zipline Tool requirement to Engine controls blueprint #5455 2025-09-16 19:26:06 +02:00
JaredWeakStrike
9fdeecd996 KH2: Remove top level client script (#5446)
* initial commit

* remove kh2client.exe from setup
2025-09-15 02:08:57 +02:00
Adrian Priestley
174d89c81f feat(workflow): Implement new Github workflow for building and pushing container images (#5242)
* fix(workflows): Update Docker workflow tag pattern
- Change tag pattern from "v*" to "*.*.*" for better version matching
- Add new semver pattern type for major version

* squash! fix(workflows): Update Docker workflow tag pattern - Change tag pattern from "v*" to "*.*.*" for better version matching - Add new semver pattern type for major version

* Update docker.yml

* Update docker.yml

* Update docker.yml

* fix(docker): Correct copy command to use recursive flag for EnemizerCLI
- Changed 'cp' to 'cp -r' to properly copy EnemizerCLI directory

* fixup! Update docker.yml

* fix(docker): Correct copy command to use recursive flag for EnemizerCLI
- Changed 'cp' to 'cp -r' to properly copy EnemizerCLI directory

* chore(workflow): Update Docker workflow to support multiple platforms
- Removed matrix strategy for platform selection
- Set platforms directly in the Docker Buildx step

* docs(deployment): Update container deployment documentation
- Specify minimum versions for Docker and Podman
- Add requirement for Docker Buildx plugin

* fix(workflows): Exclude specific paths from Docker build triggers
- Prevent unnecessary builds for documentation and deployment files

* feat(ci): Update Docker workflow for multi-architecture builds
- Added new build job for ARM64 architecture support
- Created a multi-arch manifest to manage image variants
- Improved Docker Buildx setup and push steps for both architectures

* fixup! feat(ci): Update Docker workflow for multi-architecture builds - Added new build job for ARM64 architecture support - Created a multi-arch manifest to manage image variants - Improved Docker Buildx setup and push steps for both architectures

* fixup! feat(ci): Update Docker workflow for multi-architecture builds - Added new build job for ARM64 architecture support - Created a multi-arch manifest to manage image variants - Improved Docker Buildx setup and push steps for both architectures

* fixup! feat(ci): Update Docker workflow for multi-architecture builds - Added new build job for ARM64 architecture support - Created a multi-arch manifest to manage image variants - Improved Docker Buildx setup and push steps for both architectures

* fix(workflow): Cleanup temporary image tags

* fixup! fix(workflow): Cleanup temporary image tags

* fixup! fix(workflow): Cleanup temporary image tags

* fixup! fix(workflow): Cleanup temporary image tags

* fix(workflow): Apply scoped build cache to eliminate race condition
between jobs.

* fixup! fix(workflow): Apply scoped build cache to eliminate race condition between jobs.

* Remove branch wildcard

* Test comment

* Revert wildcard removal

* Remove `pr` event

* Revert `pr` event removal

* fixup! Revert `pr` event removal

* Update docker.yml

* Update docker.yml

* Update docker.yml

* feat(workflows): Add docker workflow to compute final tags
- Introduce a step to compute final tags based on GitHub ref type
- Ensure 'latest' tag is set for version tags

* chore(workflow): Enable manual dispatch for Docker workflow
- Add workflow_dispatch event trigger to allow manual runs

* fix(workflows): Update Docker workflow to handle tag outputs correctly
- Use readarray to handle tags as an array
- Prevent duplicate latest tags in the tags list
- Set multiline output for tags in GitHub Actions

* Update docker.yml

Use new `is_not_default_branch` condition

* Update docker.yml

Allow "v" prefix for semver git tags qualifying for `latest` image tag

* Update docker.yml

Tighten up `tags` push pattern mirroring that of `release` workflow.

* Merge branch 'ArchipelagoMW:main' into main

* Update docker.yml

* Merge branch 'ArchipelagoMW:main' into docker_wf

* Update docker.yml

Use new `is_not_default_branch` condition

* Update docker.yml

Allow "v" prefix for semver git tags qualifying for `latest` image tag

* Update docker.yml

Tighten up `tags` push pattern mirroring that of `release` workflow.

* ci(docker): refactor multi-arch build to use matrix strategy
- Consolidate separate amd64 and arm64 jobs into a single build job
- Introduce matrix for platform, runner, suffix, and cache-scope
- Generalize tag computation and build steps with matrix variables

* fixup! ci(docker): refactor multi-arch build to use matrix strategy - Consolidate separate amd64 and arm64 jobs into a single build job - Introduce matrix for platform, runner, suffix, and cache-scope - Generalize tag computation and build steps with matrix variables
2025-09-14 14:24:53 +02:00
Duck
71de33d7dd CI: Fix peer review tag on undrafting a PR (#5282)
* Move ready for review condition out of non-draft check

* Remove condition on labeler

* Revert condition
2025-09-14 00:02:03 +00:00
Fabian Dill
9c00eb91d6 WebHost: fix Internal Server Error if parallel access to /room/* happens (#5444) 2025-09-14 02:01:41 +02:00
NewSoupVi
597583577a KH1: Remove top level script & remove script_name from its component (#5443) 2025-09-13 16:07:13 +02:00
Salzkorn
4e085894d2 SC2: Region access rule speedups (#5426) 2025-09-12 23:48:29 +02:00
Yaranorgoth
76a8b0d582 CCCharles: Bug fix for cyclic connections of Entrances with the ignored rules by the logic (#5442)
* Add cccharles world to AP

> The logic has been tested, the game can be completed
> The logic is simple and it does not take into account options
! The documentations are a work in progress

* Update documentations

> Redacted French and English Setup Guides
> Redacted French and English Game Pages

* Handling PR#5287 remarks

> Revert unexpected changes on .run\Archipelago Unittests.run.xml (base Archipelago file)
> Fixed typo "querty" -> "qwerty" in fr and eng Game Pages
> Adding "Game page in other languages" section to eng Game Page documentation
> Improved Steam path in fr and eng Setup Guides

* Handled PR remarks + fixes

> Added get_filler_item_name() to remove warnings
> Fixed irrelevant links for documentations
> Used the Player Options page instead of the default YAML on GitHub
> Reworded all locations to make them simple and clear
> Split some locations that can be linked with an entrance rule
> Reworked all options
> Updated regions according to locations
> Replaced unnecessary rules by rules on entrances

* Empty Options.py

Only the base options are handled yet, "work in progress" features removed.

* Handled PR remark

> Fixed specific UT name

* Handled PR remarks

> UT updated by replacing depreciated features

* Add start_inventory_from_pool as option

This start_inventory_from_pool option is like regular start inventory but it takes items from the pool and replaces them with fillers

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

* Handled PR remarks

> Mainly fixed editorial and minor issues without impact on UT results (still passed)

* Update the guides according to releases

> Updated the depreciated guides because the may to release the Mod has been changed
> Removed the fixed issues from 'Known Issues'
> Add the "Mod Download" section to simplify the others sections.

* Handled PR remark

> base_id reduced to ensure it fits to signed int (32 bits) in case of future AP improvements

* Handled PR remarks

> Set topology_present to False because unnecessary
> Added an exception in case of unknown item instead of using filler classification
> Fixed an issue that caused the "Bug Spray" to be considered as filler
> Reworked the test_claire_breakers() test to ensure the lighthouse mission can only be finished if at least 4 breakers are collected

* Added Choo-Choo Charles to README.md

* CCCharles: Added rules to win

> The victory could be accessed from sphere 1, this is now fixed by adding the following items as requirements:
- Temple Key
- Green Egg
- Blue Egg
- Red Egg

* CCCharles: Fixed cyclic Entrances connections

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-09-12 23:32:42 +02:00
NewSoupVi
27e50aa81a MultiServer: Make it so hint_location doesn't set an automatic priority #4713 2025-09-11 00:42:52 +02:00
Ben Dixon
aaaceebd91 Timespinner: Add Boss Rando Type Options (#4466)
* adding in boss rando type options for Timespinner

* removing new options from the backwards compatible section

* adding in boss rando type options for Timespinner

* removing new options from the backwards compatible section

* re-adding accidentally deleted line

* better documenting the different boss rando types

* adding missing options to the interpret_slot_data function

* making boss override schema more strict and allow for weights

* now actually rolling using the weights for boss rando overrides

* adding boss rando overrides to the spoiler header

* simplifying the schema for the manual boss mappings
2025-09-10 23:56:04 +02:00
gaithern
1322ce866e Kingdom Hearts: Adding a bunch of new features (#5078)
* Change vanilla_emblem_pieces to randomize_emblem_pieces

* Add jungle slider and starting tools options

* Update option name and add preset

* GICU changes

* unnecessary

* Update Options.py

* Fix has_all

* Update Options.py

* Update Options.py

* Some potenitial logic changes

* Oops

* Oops 2

* Cups choice options

* typos

* Logic tweaks

* Ice Titan and Superboss changes

* Suggested change and one more

* Updating some other option descriptions for clarity/typos

* Update Locations.py

* commit

* SYNTHESIS

* commit

* commit

* commit

* Add command to change communication path

I'm not a python programmer, so do excuse the code etiquette. This aims to allow Linux users to communicate to their proton directory.

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* Update Client.py

* Update Locations.py

* Update Regions.py

* commit

* commit

* commit

* Update Rules.py

* commit

* commit

* commit

* commit logic changes and linux fix from other branch

* commit

* commit

* Update __init__.py

* Update Rules.py

* commit

* commit

* commit

* commit

* add starting accessory setting

* fix starting accessories bug

* Update Locations.py

* commit

* add ap cost rando

* fix some problem locations

* add raft materials

* Update Client.py

* OK WORK THIS TIME PLEASE

* Corrected typos

* setting up for logic difficulty

* commit 1

* commit 2

* commit 3

* minor error fix

* some logic changes and fixed some typos

* tweaks

* commit

* SYNTHESIS

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* commit

* Update Client.py

* Update Locations.py

* Update Regions.py

* commit

* commit

* commit

* Update Rules.py

* commit

* commit

* commit

* commit logic changes and linux fix from other branch

* commit

* commit

* Update __init__.py

* Update Rules.py

* commit

* commit

* commit

* commit

* add starting accessory setting

* fix starting accessories bug

* Update Locations.py

* commit

* add ap cost rando

* fix some problem locations

* add raft materials

* Update Client.py

* cleanup

* commit 4

* tweaks 2

* tweaks 3

* Reset

* Update __init__.py

* Change vanilla_emblem_pieces to randomize_emblem_pieces

* Add jungle slider and starting tools options

* unnecessary

* Vanilla Puppies Part 1

The easy part

* Update __init__.py

I'm not certain this is the exact right chest for Tea Party Garden, Waterfall Cavern, HT Cemetery, or Neverland Hold but logically it's the same. 
Will do a test run later and fix if need be

* Vanilla Puppies Part 3

Wrong toggle cause I just copied over Emblem Pieces oops

* Vanilla Puppies Part 4

Forgor commented out code

* Vanilla Puppies Part 5

I now realize how this works and that what I had before was redundant

* Update __init__.py

Learning much about strings

* cleanup

* Update __init__.py

Only missed one!

* Update option name and add preset

* GICU changes

* Update Options.py

* Fix has_all

* Update Options.py

* Update Options.py

* Cups choice options

* typos

* Ice Titan and Superboss changes

* Some potenitial logic changes

* Oops

* Oops 2

* Logic tweaks

* Suggested change and one more

* Updating some other option descriptions for clarity/typos

* Update Locations.py

* Add command to change communication path

I'm not a python programmer, so do excuse the code etiquette. This aims to allow Linux users to communicate to their proton directory.

* Moving over changes from REVAMP

* whoops

* Fix patch files on the website

* Update test_goal.py

* commit

* Update worlds/kh1/__init__.py

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

* change some default options

* Missed a condition

* let's try that

* Update Options.py

* unnecessary sub check

* Some more cleanup

* tuples

* add icon

* merge cleanup

* merge cleanup 2

* merge clean up 3

* Update Data.py

* Fix cups option

* commit

* Update Rules.py

* Update Rules.py

* phantom tweak

* review commit

* minor fixes

* review 2

* minor typo fix

* minor logic tweak

* Update Client.py

* Update __init__.py

* Update Rules.py

* Olympus Cup fixes

* Update Options.py

* even MORE tweaks

* commit

* Update Options.py

* Update has_x_worlds

* Update Rules.py

* commit

* Update Options.py

* Update Options.py

* Update Options.py

* tweak 5

* Add Stacking Key Items and Halloween Town Key Item Bundle

* Update worlds/kh1/Rules.py

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

* Update Rules.py

* commit

* Update worlds/kh1/__init__.py

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

* Update __init__.py

* Update __init__.py

* whoops

* Update Rules.py

* Update Rules.py

* Fix documentation styling

* Clean up option help text

* Reordering options so they're consistent and fixing a logic bug when EOTW Unlock is item but door is emblems

* Make have x world logic consider if the player has HAW on or not

* Fix Atlantica beginner logic things, vanilla keyblade stats being broken, and some behind boss locations

* Fix vanilla puppy option

* hotfix for crabclaw logic

* Fix defaults and some boss locations

* Fix server spam

* Remove 3 High Jump Item Workshop Logic, small client changes

* Updates for PR

---------

Co-authored-by: esutley <ecsutley@gmail.com>
Co-authored-by: Goblin God <37878138+esutley@users.noreply.github.com>
Co-authored-by: River Buizel <4911928+rocket0634@users.noreply.github.com>
Co-authored-by: omnises <OmnisGamers@gmail.com>
Co-authored-by: Omnises Nihilis <38057571+Omnises@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-09-10 23:49:32 +02:00
Colin
78b529fc23 Timespinner: Adds Lantern Check flags, Missing Traps (#5188)
* Timespinner: Add Torch Flags

* Add comment of all torch locations

* Add gyre and dark forest lanterns

* Add Ancient Pyramid

* Don't make cube default progression

* Add Emperors Tower

* Add lake desolation, forest

* Add lab

* Add library, varndagroth

* Add hangar

* Add ramparts

* Add Xarion

* Add castle keep

* Add royal towers

* Add lake serene

* Add remaining checks

* Add missing region

* Fix region names

* Fix location id

* Add traps to settings

* Add restriction to elevator keycard torch

* Set new traps to have quantity 0 by default

* Scythe is now useful due to torch shredding

* Add additional lantern

* Un-disable missing lantern

* Include location ids in tracker

* Remove additional space

* Fix paren

* Add missing lantern

* Remove tablet requirement for torches

* Update filler V card

* Fix brackets

* Address feedback
2025-09-10 16:27:13 +02:00
Rosalie
9aa0bf7245 FF1: New Maintainership (#5027)
* Submitting myself for FF1 maintainership

* Uncommented an important line.
2025-09-10 00:42:32 +02:00
Fabian Dill
287bb638a0 Docs: Kivy Style (#5425)
Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-09-09 19:33:31 +02:00
PoryGone
18ac9210cb SA2B: Logic Fixes and Black Market Trap Name Improvements (#5427)
* Logic fixes and more Chao and Fake Item names

* Fix typo

* Overhaul Shop Trap Item names
2025-09-09 03:29:31 +02:00
qwint
17dad8313e Test: Remove most dependencies on lttp (#5338)
* removes the last dependencies on lttp in tests

* removing test.bases.TestBase from docs as well

* rename bases

* move imports to bases
2025-09-08 21:36:26 +02:00
qwint
63f3512829 Core: adds a custom KeyError for invalid item names (#4223)
* adds a custom KeyError for raising on world.create_item() if the passed in name is invalid

* Update __init__.py

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-09-08 11:11:46 +02:00
lgbarrere
1b200fb20b Choo-Choo Charles: implement new game and documentations (#5287)
* Add cccharles world to AP

> The logic has been tested, the game can be completed
> The logic is simple and it does not take into account options
! The documentations are a work in progress

* Update documentations

> Redacted French and English Setup Guides
> Redacted French and English Game Pages

* Handling PR#5287 remarks

> Revert unexpected changes on .run\Archipelago Unittests.run.xml (base Archipelago file)
> Fixed typo "querty" -> "qwerty" in fr and eng Game Pages
> Adding "Game page in other languages" section to eng Game Page documentation
> Improved Steam path in fr and eng Setup Guides

* Handled PR remarks + fixes

> Added get_filler_item_name() to remove warnings
> Fixed irrelevant links for documentations
> Used the Player Options page instead of the default YAML on GitHub
> Reworded all locations to make them simple and clear
> Split some locations that can be linked with an entrance rule
> Reworked all options
> Updated regions according to locations
> Replaced unnecessary rules by rules on entrances

* Empty Options.py

Only the base options are handled yet, "work in progress" features removed.

* Handled PR remark

> Fixed specific UT name

* Handled PR remarks

> UT updated by replacing depreciated features

* Add start_inventory_from_pool as option

This start_inventory_from_pool option is like regular start inventory but it takes items from the pool and replaces them with fillers

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

* Handled PR remarks

> Mainly fixed editorial and minor issues without impact on UT results (still passed)

* Update the guides according to releases

> Updated the depreciated guides because the may to release the Mod has been changed
> Removed the fixed issues from 'Known Issues'
> Add the "Mod Download" section to simplify the others sections.

* Handled PR remark

> base_id reduced to ensure it fits to signed int (32 bits) in case of future AP improvements

* Handled PR remarks

> Set topology_present to False because unnecessary
> Added an exception in case of unknown item instead of using filler classification
> Fixed an issue that caused the "Bug Spray" to be considered as filler
> Reworked the test_claire_breakers() test to ensure the lighthouse mission can only be finished if at least 4 breakers are collected

* Added Choo-Choo Charles to README.md

* CCCharles: Added rules to win

> The victory could be accessed from sphere 1, this is now fixed by adding the following items as requirements:
- Temple Key
- Green Egg
- Blue Egg
- Red Egg

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-09-08 10:37:51 +02:00
Duck
8a091c9e02 Kivy: Fix MessageBox popups (#5193) 2025-09-06 19:21:36 +02:00
black-sliver
c3c517a200 DS3: use yaml.safe_load (#5360) 2025-09-06 19:09:41 +02:00
black-sliver
c5b404baa8 CI: only trigger release action for bare semver (#5065) 2025-09-05 23:33:13 +02:00
NewSoupVi
77cab13827 ArchipIDLE: Remove game #5422 2025-09-05 23:20:37 +02:00
Scipio Wright
31b2eed1f9 TUNIC: Make the local_fill option show up on the website #5348 2025-09-05 17:09:33 +02:00
Fabian Dill
e23720a977 LttP: shuffle around gitignore (#5307) 2025-09-05 16:48:39 +02:00
Fabian Dill
90058ee175 CommonClient: fix /items, /locations and /missing not working if the datapackage is local (#5350) 2025-09-05 16:48:15 +02:00
Ziktofel
5c6dbdd98f SC2: Update docs for Linux launch script to follow the core client migration (#5407) 2025-09-05 16:44:28 +02:00
Ziktofel
8c2d246a53 SC2: Restrict allow Orphan to missions that already require that (#5405)
* Restrict Allow Orphan for items to missions that already require that

* Add test for build mission orphan behavior

* Update item lists for Allow Orphan flag

* Update the unit test to clear that BotB is not in the mission list

* Update unit test name
2025-09-05 16:44:01 +02:00
qwint
0d26b6426f Core: Remove lttp module requirement from generation #5384 2025-09-05 16:42:12 +02:00
Alchav
b9fb5c8b44 Super Mario Land 2: Remove erroneous Coinsanity checks #5364
Co-authored-by: alchav <alchav@jalchavware.com>
2025-09-05 16:37:31 +02:00
Scipio Wright
e518e41f67 Hollow Knight: Make the connecting header separate from the yaml one (#5353)
* Update setup_en.md

* Update setup_pt_br.md
2025-09-05 16:34:57 +02:00
qwint
7a38e44e64 Test: Deprecate TestBase (#5339)
* deprecate TestBase and fix the last use of it in main

* actually delete it because test discovery also imports it lmao
2025-09-05 16:24:20 +02:00
Jérémie Bolduc
64d3c55d62 Stardew Valley: Add money logic to traveling merchant (#5327)
* add rule to traveling merchant region

* add a test so kaito is happy
2025-09-05 16:23:25 +02:00
Kaito Sinclaire
89be26a33a Heretic: Update Steam URL (#5304) 2025-09-05 16:22:11 +02:00
Kaito Sinclaire
5b5e2c3567 SMZ3: Fix distribution of SM prizes (#5303) 2025-09-05 16:21:08 +02:00
Exempt-Medic
ef59a5ee11 TUNIC: Change non_local_items Earlier (#5249) 2025-09-04 19:04:21 -04:00
Scipio Wright
b0b3e3668f TUNIC: The Big Refactor (#5195)
* Make it actually return false if it gets to the backup lists and fails them

* Fix stuff after merge

* Add outlet regions, create new regions as needed for them

* Put together part of decoupled and direction pairs

* make direction pairs work

* Make decoupled work

* Make fixed shop work again

* Fix a few minor bugs

* Fix a few minor bugs

* Fix plando

* god i love programming

* Reorder portal list

* Update portal sorter for variable shops

* Add missing parameter

* Some cleanup of prints and functions

* Fix typo

* it's aliiiiiive

* Make seed groups not sync decoupled

* Add test with full-shop plando

* Fix bug with vanilla portals

* Handle plando connections and direction pair errors

* Update plando checking for decoupled

* Fix typo

* Fix exception text to be shorter

* Add some more comments

* Add todo note

* Remove unused safety thing

* Remove extra plando connections definition in options

* Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log

* Fix weird edge case that is technically user error

* Add note to fixed shop

* Fix parsing shop names in UT

* Remove debug print

* Actually make UT work

* multiworld. to world.

* Fix typo from merge

* Make it so the shops show up in the entrance hints

* Fix bug in ladder storage rules

* Remove blank line

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/rules.py
#	worlds/tunic/test/test_access.py

* Fix issues after merge

* Update plando connections stuff in docs

* Make early bushes only contain grass

* Fix library mistake

* Backport changes to grass rando (#20)

* Backport changes to grass rando

* add_rule instead of set_rule for the special cases, add special cases for back of swamp laurels area cause I should've made a new region for the swamp upper entrance

* Remove item name group for grass

* Update grass rando option descriptions

- Also ignore grass fill for single player games

* Ignore grass fill option for solo rando

* Update er_rules.py

* Fix pre fill issue

* Remove duplicate option

* Add excluded grass locations back

* Hide grass fill option from simple ui options page

* Check for start with sword before setting grass rules

* Update worlds/tunic/options.py

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

* has_stick -> has_melee

* has_stick -> has_melee

* Add a failsafe for direction pairing

* Fix playthrough crash bug

* Remove init from logicmixin

* Updates per code review (thanks hesto)

* has_stick to has_melee in newer update

* has_stick to has_melee in newer update

* Exclude grass from get_filler_item_name

- non-grass rando games were accidentally seeing grass items get shuffled in as filler, which is funny but probably shouldn't happen

* Update worlds/tunic/__init__.py

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

* Apply suggestions from code review

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

* change the rest of grass_fill to local_fill

* Filter out grass from filler_items

* remove -> discard

* Update worlds/tunic/__init__.py

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

* Starting out

* Rules for breakable regions

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/combat_logic.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py

* Cleanup more stuff after merge

* Revert "Cleanup more stuff after merge"

This reverts commit a6ee9a93da.

* Revert "# Conflicts:"

This reverts commit c74ccd74a4.

* Cleanup more stuff after merge

* change has_stick to has_melee

* Update grass list with combat logic regions

* More fixes from combat logic merge

* Fix some dumb stuff (#21)

* Reorganize pre fill for grass

* make the rest of it work, it's pr ready, boom

* Make it work in not pot shuffle

* Merge grass rando

* multiworld -> world get_location, use has_any

* Swap out region for West Garden Before Terry grass

* Adjust west garden rules to add west combat region

* Adjust grass regions for south checkpoint grass

* Adjust grass regions for after terry grass

* Adjust grass regions for west combat grass

* Adjust grass regions for dagger house grass

* Adjust grass regions for south checkpoint grass, adjust regions and rules for some related locations

* Finish the remainder of the west garden grass, reformat ruined atoll a little

* More hex quest updates

- Implement page ability shuffle for hex quest
- Fix keys behind bosses if hex goal is less than 3
- Added check to fix conflicting hex quest options
- Add option to slot data

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Merge in hex hunt

* Merge in changes

* Clean up imports

* Add ability type to UT stuff

* merge it all

* Make local fill work across pot and grass (to be adjusted later)

* Make separate pools for the grass and non-grass fills

* Fix id overlap

* Update option description

* Fix default

* Reorder localfill option desc

* Load the purgatory ones in

* Adjustments after merge

* Fully remove logicrules

* Fix UT support with fixed shop option

* Add breakable shuffle to the ut stuff

* Make it load in a specific number of locations

* Add Silent's spoiler log ability thing

* Fix for groups

* Fix for groups

* Fix typo

* Fix hex quest UT support

* Use .get

* UT fixes, classification fixes

* Rename some locations

* Adjust guard house names

* Adjust guard house names

* Rework create_item

* Fix for plando connections

* Rename, add new breakables

* Rename more stuff

* Time to rename them again

* Fix issue with fixed shop + decoupled

* Put in an exception to catch that error in the future

* Update create_item to match main

* Update spoiler log lines for hex abilities

* Burn the signs down

* Bring over the combat logic fix

* Merge in combat logic fix

* Silly static method thing

* Move a few areas to before well instead of east forest

* Add an all_random hidden option for dev stuff

* Port over changes from main

* Fix west courtyard pot regions

* Remove debug prints

* Fix fortress courtyard and beneath the fortress loc groups again

* Add exception handling to deal with duplicate apworlds

* Fix typo

* More missing loc group conversions

* Initial fuse shuffle stuff

* Fix gun missing from combat_items, add new for combat logic cache, very slight refactor of check_combat_reqs to let it do the changeover in a less complicated fashion, fix area being a boss area rather than non-boss area for a check

* Add fuse shuffle logic

* reorder atoll statue rule

* Update traversal reqs

* Remove fuse shuffle from temple door

* Combine rules and option checking

* Add bell shuffle; fix fuse location groups

* Fix portal rules not requiring prayer

* Merge the grass laurels exit grass PR

* Merge in fortress bridge PR

* Do a little clean up

* Fix a regression

* Update after merge

* Some more stuff

* More Silent changes

* Update more info section in game info page

* Fix rules for atoll and swamp fuses

* Precollect cathedral fuse in ER

* actually just make the fuse useful instead of progression

* Add it to the swamp and cath rules too

* Fix cath fuse name

* Minor fixes and edits

* Some UT stuff

* Fix a couple more groups

* Move a bunch of UT stuff to its own file

* Fix up a couple UT things

* Couple minor ER fixes

* Formatting change

* UT poptracker stuff enabled since it's optional in one of the releases

* Add author string to world class

* Adjust local fill option name

* Update ut_stuff to match the PR

* Add exception handling for UT with old apworld

* Fix missing tracker_world

* Remove extra entrance from cath main -> elevator

Entry <-> Elev exists,
Entry <-> Main exists
So no connection is needed between Main and Elev

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Fix so that decoupled doesn't incorrectly use get_portal_info and get_paired_portal

* Update for breakables poptracker

* Backup and warnings instead

* Update typing

* Delete old regions and rules, move stuff to logic_helpers and constants

* Delete now much less useful tests

* Fix breakables map tracking

* Add more comments to init

* Add todo to grass.py

* Fix up tests

* Pull out fuse and bell shuffle

* Pull out fuse and bell shuffle

* Update worlds/tunic/options.py

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

* Update worlds/tunic/logic_helpers.py

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

* {} -> () in state functions

* {} -> () in state functions

* Change {} -> () in state functions, use constant for gun

* Remove floating constants in er_data

* Finish hard deprecating FixedShop

* Finish hard deprecating FixedShop

* Fix zig skip showing up in decoupled fixed shop

---------

Co-authored-by: silent-destroyer <osilentdestroyer@gmail.com>
Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
2025-09-05 00:44:32 +02:00
PoryGone
42ace29db4 Celeste 64: Logic Fixes #5417 2025-09-04 21:53:55 +02:00
qwint
03992c43d9 Docs: update mac install instructions to reflect 3.13 support (#5411) 2025-09-04 16:58:24 +02:00
PoryGone
e342a20fde Celeste (Open World): Post-merge Logic Fix (#5415)
* APWorld Skeleton

* Hair Color Rando and first items

* All interactable items

* Checkpoint Items and Locations

* First pass sample intermediate data

* Bulk of Region/location code

* JSON Data Parser

* New items and Level Item mapping

* Data Parsing fixes and most of 1a data

* 1a complete data and region/location/item creation fixes

* Add Key Location type and ID output

* Add options to slot data

* 1B Level Data

* Added Location logging

* Add Goal Area Options

* 1c Level Data

* Old Site A B C level data

* Key/Binosanity and Hair Length options

* Key Item/Location and Clutter Event handling

* Remove generic 'keys' item

* 3a level data

* 3b and 3c level data

* Chapter 4 level data

* Chapter 5 Logic Data

* Chapter 5 level data

* Trap Support

* Add TrapLink Support

* Chapter 6 A/B/C Level Data

* Add active_levels to slot_data

* Item and Location Name Groups + style cleanups

* Chapter 7 Level Data and Items, Gemsanity option

* Goal Area and victory handling

* Fix slot_data

* Add Core Level Data

* Carsanity

* Farewell Level Data and ID Range Update

* Farewell level data and handling

* Music Shuffle

* Require Cassettes

* Change default trap expiration action to Deaths

* Handle Poetry

* Mod versioning

* Rename folder, general cleanup

* Additional Cleanup

* Handle Farewell Golden Goal when Include Goldens is off

* Better handling of Farewell Golden

* Update Docs

* Beta test bug fixes

* Bump to v1.0.0

* Update Changelog

* Several Logic tweaks

* Update APWorld Version

* Add Celeste (Open World) to README

* Peer review changes

* Logic Fixes:

* Adjust Mirror Temple B Key logic

* Increment APWorld version

* Fix several logic bugs

* Add missing link

* Add Item Name Groups for common alternative item names

* Account for Madeline's post-Celeste hair-dying activities

* Account for ignored member variable and hardcoded color in Celeste codebase

* Add Blue Clouds to the logic of reaching Farewell - intro-02-launch

* Type checking workaround

* Bump version number

* Adjust Setup Guide

* Minor typing fixes

* Logic and PR fixes

* Increment APWorld Version

* Use more world helpers

* Core review

* CODEOWNERS

* Minor logic fix and insert APWorld version into spoiler

* Fix merge error
2025-09-04 03:50:59 +02:00
threeandthreee
3c28db0800 LADX: Drop a marin text option that makes patching fail #5398 2025-09-03 21:02:32 +02:00
qwint
8f88152532 MultiServer: Validate CreateHints status arg #5408 2025-09-03 21:01:56 +02:00
qwint
7a1311984f Docs: Update max py version to 3.13 #5410 2025-09-03 21:00:36 +02:00
Ziktofel
a9f594d6b2 SC2: Remove Starcraft2Client.py as Launcher.py got upgraded to work under Python 3.13 (#5406) 2025-09-02 23:50:29 +02:00
Ziktofel
5f1835c546 SC2: Content update (#5312)
Feature highlights:
- Adds many content to the SC2 game
- Allows custom mission order
- Adds race-swapped missions for build missions (except Epilogue and NCO)
- Allows War Council Nerfs (Protoss units can get pre - War Council State, alternative units get another custom nerf to match the power level of base units)
- Revamps Predator's upgrade tree (never was considered strategically important)
- Adds some units and upgrades
- Locked and excluded items can specify quantity
- Key mode (if opt-in, missions require keys to be unlocked on top of their regular regular requirements
- Victory caches - Victory locations can grant multiple items to the multiworld instead of one 
- The generator is more resilient for generator failures as it validates logic for item excludes
- Fixes the following issues:
  - https://github.com/ArchipelagoMW/Archipelago/issues/3531 
  - https://github.com/ArchipelagoMW/Archipelago/issues/3548
2025-09-02 17:40:58 +02:00
Duck
2359cceb64 AHiT: Add Death Link amnesty options (#4694)
* Add basic death link amnesty option

* Add death wish amnesty option
2025-09-02 05:29:31 +02:00
Rhenaud Dubois
a0a1c5d4c0 Pokemon Emerald: Added Pokemon Gen 3 Adjuster data (#5145)
* Added Pokemon Gen 3 Adjuster data

* Updated extracted data

* Commented out adjuster docs for now

* Replace <b> in the docs markers with **
2025-09-02 01:56:52 +02:00
qwint
14d65fdf28 Docs: Add doc for shared cache (#5129)
* adds doc file describing what the shared cache is, how to use it, and what you can currently expect in it

* Update docs/shared_cache.md

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

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-09-02 01:54:34 +02:00
qwint
5fd9570368 Docs: Add section about adding Components (#5097)
* kinda driven by wanting to test the labeling change in prod but also components are a weird part of the ecosystem and could use more documentation.

* additional text describing launch/launch_subprocess and their use

* Update docs/adding games.md

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

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-09-02 01:53:58 +02:00
PoryGone
c753fbff2d Celeste (Open World): Implement New Game (#4937)
* APWorld Skeleton

* Hair Color Rando and first items

* All interactable items

* Checkpoint Items and Locations

* First pass sample intermediate data

* Bulk of Region/location code

* JSON Data Parser

* New items and Level Item mapping

* Data Parsing fixes and most of 1a data

* 1a complete data and region/location/item creation fixes

* Add Key Location type and ID output

* Add options to slot data

* 1B Level Data

* Added Location logging

* Add Goal Area Options

* 1c Level Data

* Old Site A B C level data

* Key/Binosanity and Hair Length options

* Key Item/Location and Clutter Event handling

* Remove generic 'keys' item

* 3a level data

* 3b and 3c level data

* Chapter 4 level data

* Chapter 5 Logic Data

* Chapter 5 level data

* Trap Support

* Add TrapLink Support

* Chapter 6 A/B/C Level Data

* Add active_levels to slot_data

* Item and Location Name Groups + style cleanups

* Chapter 7 Level Data and Items, Gemsanity option

* Goal Area and victory handling

* Fix slot_data

* Add Core Level Data

* Carsanity

* Farewell Level Data and ID Range Update

* Farewell level data and handling

* Music Shuffle

* Require Cassettes

* Change default trap expiration action to Deaths

* Handle Poetry

* Mod versioning

* Rename folder, general cleanup

* Additional Cleanup

* Handle Farewell Golden Goal when Include Goldens is off

* Better handling of Farewell Golden

* Update Docs

* Beta test bug fixes

* Bump to v1.0.0

* Update Changelog

* Several Logic tweaks

* Update APWorld Version

* Add Celeste (Open World) to README

* Peer review changes

* Logic Fixes:

* Adjust Mirror Temple B Key logic

* Increment APWorld version

* Fix several logic bugs

* Add missing link

* Add Item Name Groups for common alternative item names

* Account for Madeline's post-Celeste hair-dying activities

* Account for ignored member variable and hardcoded color in Celeste codebase

* Add Blue Clouds to the logic of reaching Farewell - intro-02-launch

* Type checking workaround

* Bump version number

* Adjust Setup Guide

* Minor typing fixes

* Logic and PR fixes

* Increment APWorld Version

* Use more world helpers

* Core review

* CODEOWNERS
2025-08-31 23:31:09 +02:00
Jérémie Bolduc
cdf7165ab4 Stardew Valley: Use new asserts in tests (#4621)
* changes

* cherry pick stuff

* use newly create methods more

* use new assets to ease readability

* remove unneeded assert

* add assert region adapters

* use new asserts yay

* self review

* self review

* review

* replace parrot express with transportation constant

* bullshit commit again

* revert a bunch of off topic changes

* these changes seems to be on topic

* revert some undesired merge changes

* review imports

* use type instead of instance in some options

* properly return super

* review

* change one str to use a constnat
2025-08-31 16:21:23 +02:00
Etsuna
893acd2f02 Webserver: fix activity_timers for api tracker.py (#5385) 2025-08-31 14:12:32 +02:00
sgrunt
34aaa44b1f Timespinner: add support for spider traps from new client release (#4848)
Co-authored-by: sgrunt <sgrunt1987@gmail.com>
2025-08-31 01:09:22 +02:00
NewSoupVi
f2461a2fea WebHost: Ensure that OptionSets and OptionLists get exported to yaml, even when nothing is selected (#5240)
* Ensure that OptionSets and OptionLists get exported to yaml, even if nothing is selected

* forgot ItemSet and LocationSet

* Make it even less likely for there to be overlap
2025-08-30 22:33:43 +02:00
Justus Lind
bb2ecb8a97 Muse Dash: Change Exception to Option Error and Update to Muse Radio FM106 (#5374)
* Change Exception to OptionError

* Update to Muse Radio FM106.

* Add Scipio's suggestion.

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-08-29 17:41:29 +02:00
Rosalie
439be48f36 [TLOZ] Remove deprecated Utils.get_options call, part 2 (#5371)
* Updated to remove deprecated call.

* Removed unused argument.

* Removed errant client calls to Utils.get_options, and fixed call in Rom.py that was passing an argument.
2025-08-27 19:28:42 +02:00
PoryGone
750c8a9810 Stop using get_options (#5341) 2025-08-27 15:21:53 +02:00
lordlou
e11b40c94b [SM, SMZ3] get options deprecation (#5257)
* - 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

* replaced deprecated usage of Utils.get_options with settings.get_settings in SM and SMZ3
2025-08-27 15:21:28 +02:00
Rosalie
be51fb9ba9 [TLOZ] Updated to remove deprecated call. (#5266)
* Updated to remove deprecated call.

* Removed unused argument.
2025-08-27 15:20:51 +02:00
Ishigh1
e1fca86cf8 Core: Improved GER's caching of visited nodes during initialization (#5366)
* Moved the visited update

* Renamed visited to seen
2025-08-27 02:36:47 +02:00
black-sliver
1fa342b085 Core: add python 3.13 support (#5357)
* Core: fix freeze support for py3.13+

Loading Utils now patches multiprocessing.freeze_support()
Utils.freeze_support() is now deprecated

* WebHost: use pony fork on py3.13

* CI: test with py3.13
2025-08-25 17:36:39 +00:00
NewSoupVi
d146d90131 Core: Fix Priority Fill *not* crashing when it should, in cases where there is no deprioritized progression #5363 2025-08-25 17:52:04 +02:00
NewSoupVi
d5bdac02b7 Docs: Add deprioritized to AP API doc (#5355)
Did this on my phone while in the bathroom :)
2025-08-24 02:54:49 +02:00
Exempt-Medic
dfd7cbf0c5 Tests: Standardize World Exclusions, Strengthen LCS Test (#4423) 2025-08-23 18:36:25 -04:00
Aaron Wagener
88a4a589a0 WebHost: add a tracker api endpoint (#1052)
An endpoint from the tracker page.
2025-08-23 08:33:46 +02:00
Duck
bead81b64b Core: Fix get_unique_identifier failing on missing cache folder (#5322) 2025-08-21 07:46:06 +02:00
black-sliver
16d5b453a7 Core: require setuptools>=75 (#5346)
Setuptools 70.3.0 seems to not work for us.
2025-08-19 19:35:50 +02:00
massimilianodelliubaldini
48906de873 Jak and Daxter: fix checks getting lost if player disconnects. (#5280) 2025-08-19 18:08:39 +02:00
Nicholas Saylor
9a64b8c5ce Webhost: Remove showdown.js Remnants (#4984) 2025-08-18 02:48:56 +02:00
Silvris
6ba2b7f8c3 Tests: implement pattern for filtering unittests locally (#5080) 2025-08-18 02:46:48 +02:00
Flit
6f7ca082f2 Docker: use python:3.12-slim-bookworm (#5343) 2025-08-17 20:47:01 +02:00
Faris
eb09be3594 OSRS: Fix UT Integration and Various Gen Failures (#5331) 2025-08-16 17:08:44 -04:00
Fabian Dill
9d654b7e3b Core: drop Python 3.10 (#5324)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-08-15 18:45:40 +02:00
Doug Hoskisson
8f7fcd4889 Zillion: Move completion_condition Definition Earlier (#5279) 2025-08-15 08:55:11 -04:00
black-sliver
b85887241f CI: update appimagetool hash (#5333) 2025-08-15 12:36:13 +02:00
Fabian Dill
5110676c76 Core: 0.6.4 (#5314) 2025-08-15 11:44:24 +02:00
JaredWeakStrike
0020e6c3d3 KH2: Fix html headers to be markdown (#5305)
* update setup guide

* Update worlds/kh2/docs/setup_en.md

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

* Update worlds/kh2/docs/setup_en.md

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

* Update en_Kingdom Hearts 2.md

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-08-12 23:35:25 +02:00
LiquidCat64
6e6fd0e9bc CV64 and CotM: Correct Archipleago (#5323) 2025-08-12 22:01:29 +02:00
black-sliver
85c26f9740 WebHost: redirect old tutorials to new URL (#5319)
* WebHost: redirect old tutorials to new URL

* WebHost: make comment in tutorial_redirect more accurate
2025-08-12 15:38:22 +00:00
Fabian Dill
9057ce0ce3 WebHost: fix links on sitemap, switch to url_for and add test to prevent future breakage (#5318) 2025-08-12 16:52:34 +02:00
black-sliver
378cc91a4d CI: update appimage runtime (#5315) 2025-08-12 02:41:43 +02:00
qwint
cdde38fdc9 Settings: warn for broken worlds instead of crashing (#4438)
note: i swear the issue was an importerror but i could only get attributeerrors on the getattr() call, maybe we want to check for both?
2025-08-10 17:23:39 +02:00
Adrian Priestley
c34c00baa4 fix(deps): Lock setuptools version to <81 (#5284)
- Update Dockerfile to specify "setuptools<81"
- Modify ModuleUpdate.py to install setuptools with version constraint
2025-08-10 17:09:31 +02:00
Mysteryem
9bd535752e Core: Sort Unreachable Locations Written to the Spoiler (#5269) 2025-08-10 11:03:12 -04:00
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
Justus Lind
a9b35de7ee Muse Dash: Update song list to Rotaeno Update/7th Anniversary (#5066) 2025-07-12 09:02:49 -04:00
Scipio Wright
125d053b61 TUNIC: Fix missing line for UT stuff #5185 2025-07-12 13:52:02 +02:00
Scipio Wright
585cbf95a6 TUNIC: Add UT Support for Breakables (#5182) 2025-07-12 07:14:34 -04:00
Jérémie Bolduc
909565e5d9 Stardew Valley: Remove Rarecrow Locations from Night Market when Museumsanity is Disabled (#5146) 2025-07-12 07:12:04 -04:00
Exempt-Medic
a79423534c LADX: Update marin.txt (#5178) 2025-07-11 18:44:26 -04:00
NewSoupVi
7a6fb5e35b Revert "Core: Take Counter back out of RestrictedUnpickler" (#5184)
* Revert "Core: Take Counter back out of RestrictedUnpickler #5169"

This reverts commit 95e09c8e2a.

* Update Utils.py
2025-07-11 23:28:18 +02:00
Zach “Phar” Parks
6af34b66fb Various: Remove Rogue Legacy and Clique (#5177)
* Various: Remove Rogue Legacy and Clique

* Remove Clique from setup.py and revert network diagram.md change.

* Try again.

* Update network diagram.md

---------

Co-authored-by: Zach “Phar” Parks <phar@pharware.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-11 19:34:46 +02:00
NewSoupVi
2974f7d11f Core: Replace Clique with V6 in unit tests (#5181)
* replace Clique with V6 in unit tests

* no hard mode in V6

* modify regex in copy_world to allow : str

* oops

* I see now

* work around all typing

* there actually needs to be something
2025-07-11 19:27:28 +02:00
Carter Hesterman
edc0c89753 CIV 6: Remove Erroneous Boost Prereqs for Computers Boost (#5134) 2025-07-10 09:10:56 -04:00
axe-y
b1ff55dd06 DLCQ: Fix/Refactor LFoD Start Inventory (#5176) 2025-07-10 08:33:52 -04:00
Remy Jette
f4b5422f66 Factorio: Fix link to world_gen documentation (#5171) 2025-07-07 22:57:55 +02:00
massimilianodelliubaldini
d4ebace99f [Jak and Daxter] Auto Detect Install Path after Game Launcher Update #5152 2025-07-07 19:15:37 +02:00
NewSoupVi
95e09c8e2a Core: Take Counter back out of RestrictedUnpickler #5169 2025-07-07 16:24:35 +02:00
Fabian Dill
4623d59206 Core: ensure slot_data and er_hint_info are only base data types (#5144)
---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-07-07 15:51:39 +02:00
Doug Hoskisson
e68b1ad428 CommonClient: fix extra panels added to main_area_container (#5151) 2025-07-06 19:22:02 +02:00
Ixrec
072e2ece15 Docs: 'get_prefill_items' -> 'get_pre_fill_items' (#5167) 2025-07-05 17:01:08 -04:00
agilbert1412
11130037fe Stardew Valley: Fixed luck level requirements for slot machines #5160
# Conflicts:
#	worlds/stardew_valley/data/craftable_data.py
2025-07-03 21:08:36 +02:00
Scipio Wright
ba66ef14cc Update world api.md (#5149) 2025-07-02 14:14:35 +02:00
Jérémie Bolduc
8aacc23882 SDV: Add "Desert Transportation" and "Island Transportation" Item Groups (#5143) 2025-06-28 11:36:09 -04:00
Jonathan Tan
03e5fd3dae TWW: Fix Swords in Swordless Mode (#5137)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-28 10:46:37 -04:00
Fly Hyping
da52598c08 Wargroove: Fix Communication Thread (#5125) 2025-06-27 19:42:35 -04:00
Jonathan Tan
52389731eb TWW: Update Preset S7 to S8 (#5138) 2025-06-27 18:46:00 -04:00
LiquidCat64
21864f6f95 CVCotM: Fix Advance Collection ROM (#5132) 2025-06-27 18:25:45 -04:00
DJ-lennart
00f8625280 Civilization VI: Updated setup and info pages (#5123)
* Update setup_en.md

Updated setup instructions for Civilization VI in Archipelago

* Update en_Civilization VI.md

Updated info page for Civilization VI in Archipelago

* Update setup_en.md
2025-06-21 16:31:12 +02:00
James White
c34e29c712 Pokemon RB: Client: Send bounce messages with current map ID (#5121) 2025-06-20 22:52:54 +02:00
palex00
e0ae3359f1 Pokémon RB: Use new link for a new tracker (#5122)
* Update setup_en.md

* Update setup_es.md
2025-06-20 20:55:49 +02:00
Katelyn Gigante
c2666bacd7 core: Don't attempt to write to the inside of an OSX App Bundle (#4380)
* core: Frozen OSX should also use Home Directory

* Use Application Support instead of homedir

* Suggested changes
2025-06-19 18:05:52 +02:00
Aaron Wagener
4eefd9c3ce Kivy: swap from the tab carousel to navigation bar (#4930)
* implement tabs as NavigationBar

* update the underline bar with the screen manager

* remove some unneeded kv

* remove the underline in favor of a full tab highlight

* fix insert transitions

* use on_release instead of on_press

* minor cleanup

* add remove_client_tab and add a caller to the NavigationBar for back compat

* unused imports

* Update kvui.py

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-06-19 13:39:26 +02:00
Silvris
211456242e KDL3: update to gifting protocol 3 and update settings usage (#4814)
* gift version 3

* update settings usage

* that really has just been broken this entire time

* remove unnecessary print

* Update client.py

* fix random flavor handling

* fix incorrect sender/receiver

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 13:00:47 -04:00
massimilianodelliubaldini
6f244c4661 Docs: Update Plando Guide and Make it More User Friendly (#4858)
* Make plando guide more user friendly.

* Apply suggestions from code review

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

* Further updates for review.

* Clear search box when filtering by type.

* Forget previous commit name - more code review updates to doc.

* Move link to yaml tutorial.

* Replace STS example with Pokemon RB.

* Use non-key item examples in RB.

* Rooby's code review updates.

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Address some more feedback.

* Make Factorio example more accurate.

* Exempt's code review updates (round 4)

* Exempt's code review updates (round 4 + 1)

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 12:54:08 -04:00
Exempt-Medic
47bf6d724b Minecraft Removal Cleanup (#5118) 2025-06-16 10:56:47 -04:00
Ixrec
5c710ad032 Docs: Rework the "Events" Section of world api.md (#5012)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 08:36:12 -04:00
BlastSlimey
dda5a05cbb shapez: Change Links to Shapesanity Cheat Sheet (#5047) 2025-06-16 08:07:27 -04:00
Natalie Weizenbaum
e0a63e0290 DS3: Link to the Appropriate .NET Runtime for Proton (#5093) 2025-06-16 08:02:06 -04:00
NewSoupVi
9246659589 Make sure ladx removes the same copy of the starting item from the itempool that it's placing (#5110) 2025-06-16 13:49:30 +02:00
digiholic
377cdb84b4 MMBN3: Fixes Generation Errors and General UX Smoothing (#5077)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-06-16 07:47:55 -04:00
KonoTyran
0e759f25fd Remove Minecraft (#4672)
* Remove Minecraft

* remove minecraft

* remove minecraft

* elif -> if

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-06-16 12:31:16 +02:00
qwint
b408bb4f6e Core: Docstring typo on Region.add_exits (#5089)
* doc typo

* Update BaseClasses.py
2025-06-16 02:31:12 +02:00
JusticePS
1356479415 AdventureClient: Replace Utils.get_settings with settings.get_settings #5043 2025-06-16 01:30:45 +02:00
Exempt-Medic
ec5b4e704f Plando Items: Better Warning for Nonexisting Worlds (#5112) 2025-06-14 09:28:02 -04:00
Exempt-Medic
aa9e617510 DS3: Apply Rules to Non-Randomized Locations (#5106) 2025-06-14 09:27:22 -04:00
Exempt-Medic
ecb739ce96 Plando Items: Fix Location Groups Unfolding (#5099) 2025-06-14 09:26:58 -04:00
Exempt-Medic
3b72140435 Shivers: Fix get_pre_fill_items (#5113) 2025-06-14 09:26:22 -04:00
Louis M
27a6770569 Aquaria: Fixing open waters urns not breakable with nature forms logic bug (#5072)
* Fixing open waters urns not breakable with nature forms logic bug

* Using list in comprehension only when useful

* Replacing damaging items by a constant

* Removing comprehension list creating from lambda
2025-06-14 13:17:33 +02:00
NewSoupVi
2ff611167a ALTTP: Fix take_any leaving a placed item in the multiworld itempool #5108 2025-06-14 12:21:25 +02:00
agilbert1412
e83e178b63 Stardew Valley: Fix 3 Logic Issues (#5094)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-13 20:29:23 -04:00
Exempt-Medic
068a757373 Item Plando: Fix count value (#5101) 2025-06-13 20:29:06 -04:00
PoryGone
0ad4527719 SA2B: Logic Fixes (#5095)
- Fixed King Boom Boo being able to appear in multiple boss gates
- `Final Rush - 16 Animals (Expert)` no longer requires `Sonic - Bounce Bracelet`
- `Dry Lagoon - 5 (Standard)` now requires `Rouge - Pick Nails`
- `Sand Ocean - Extra Life Box 2 (Standard/Hard/Expert)` no longer requires `Eggman - Jet Engine`
- `Security Hall - 8 Animals (Expert)` no longer requires `Rouge - Pick Nails`
- `Sky Rail - Item Box 8 (Standard)` now requires `Shadow - Air Shoes` and `Shadow - Mystic Melody`
- `Cosmic Wall - Chao Key 1 (Standard/Hard/Expert)` no longer requires `Eggman - Mystic Melody`
- `Cannon's Core - Pipe 2 (Expert)` no longer requires `Tails - Booster`
- `Cannon's Core - Gold Beetle` no longer requires `Tails - Booster` nor `Knuckles - Hammer Gloves`
2025-06-13 22:01:19 +02:00
qwint
8c6327d024 LTTP/SDV: use .name when appropriate in subtests (#5107) 2025-06-13 21:56:09 +02:00
qwint
aecbb2ab02 fix saving princess's use of subprocess helpers (#5103) 2025-06-13 12:28:58 +02:00
JaredWeakStrike
52b11083fe KH2: Raise Exception for Misusing DonaldGoofyStatsanity Option (#4710)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-11 15:52:47 -04:00
BadMagic100
a8c87ce54b CI: Add GH_REPO environment variable to labeler (#5081) 2025-06-10 05:55:40 +02:00
JaredWeakStrike
ddb3240591 KH2: Give warning when client has cached locations (#5000)
* a

* disconnect when connect to wrong slot

* connection to the wrong seed fix

* seed_name is always none
2025-06-09 14:58:08 +02:00
qwint
f25ef639f2 Launcher: Fix Cli Components when installed to a directory with a space (#5091) 2025-06-09 00:43:23 +02:00
BlastSlimey
ab7d3ce4aa shapez: Remove preset unittests #5086 2025-06-06 00:05:53 +02:00
Jarno
50db922cef Timespinner: Fixed generation error because of timezone locking (#5084)
* Fixed generation error because of timezone locking

* Refactored logic + prevent excluding warps when unchained keys in on
2025-06-05 15:05:00 +02:00
Ehseezed
a2708edc37 Timespinner: Fix Castle Ramparts Region Connection #5082
Co-authored-by: ehseezed <Ehseezed@users.noreply.github.com>
2025-06-04 19:51:08 +02:00
Exempt-Medic
603a5005e2 DS3: Fix Non-Crow Itemlinking and Mark Aldrich Ruby and Twin Dragon Greatshield As Missable (#4510)
* Fix Branch (Not Crow)

* Oops

* Mark Aldrich Ruby as missable

* Expand comment

* Short circuit

* Mark Twin Dragon Greatshield as missable

* Add missable cause
2025-06-03 08:49:10 -04:00
Fabian Dill
b4f68bce76 Factorio: revamp args parsing and passing (#5036) 2025-06-03 13:49:44 +02:00
Scipio Wright
a76cec1539 TUNIC: Fix decoupled ER + ladder storage making invalid entrances #5075 2025-06-03 12:51:06 +02:00
black-sliver
694e6bcae3 Launcher/Utils: reset LD_LIBRARY_PATH for system EXEs (#5022) 2025-06-03 10:42:37 +00:00
black-sliver
b85b18cf5f SoE: remove outdated info from guide (#5064)
The client does not depend on Animation Frame anymore, so it can be backgrounded.
2025-06-02 16:39:42 +00:00
Mysteryem
04c707f874 DKC3: Add missing indirect conditions (#5073)
A couple of Entrance access rules were checking for being able to reach
a Location, but a Location first checks for being able to reach its
parent Region, so it needs to be registered that access to that parent
Region can give access to the Entrance.
2025-06-02 18:06:54 +02:00
Exempt-Medic
99142fd662 Plando Items: Fix count with empty locations/location #5040 2025-06-02 18:01:21 +02:00
Mysteryem
0c5cb17d96 DLCQuest: Add missing indirect conditions (#5074)
The `Behind Rocks` and `Pickaxe Hard Cave` Entrances require being able
to reach the `Cut Content` region, but no indirect conditions were being
registered for this region.

The `set_lfod_self_obtained_items_rules` function was also using a
`world` parameter that was actually expecting a `MultiWorld` instance,
so I have renamed it for clarity and updated the function to use
`world.get_entrance()` rather than `multiworld.get_entrance()`.

Much of the rest of the file passes `MultiWorld` instances to `world`
parameters, but fixing all of these is out of the scope of the changes
in this patch, so has not been included.
2025-06-02 17:56:11 +02:00
qwint
cabde313b5 WebHost: Use expected APPlayerContainer manifest location directly when ingesting them #4754 2025-06-02 17:53:57 +02:00
qwint
8f68bb342d Core and Various Worlds: define patch_file_ending to APPlayerContainer (#5058)
* move to playercontainer

* moves patch_file_ending handling to APPlayerContainer and updates the worlds using it to define their extensions

* give oot a patch_file_ending as well
2025-06-02 17:53:18 +02:00
Jérémie Bolduc
fab75d3a32 Stardew Valley: Fix Wizard Tower and Entrance Randomizer Softlocks (#4631)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-31 07:57:42 -04:00
massimilianodelliubaldini
d19bf98dc4 Jak and Daxter: Post-merge Polish (#5031)
- Cleans up a few missed references in the setup guide.
- Refactors Options class to use metaclass and decorators to enforce friendly limits on multiple levels.    
  - Templates generated from the website, even ones with `random` should not fail generation because the website will only allow values inside the friendly limits. 
  - _Uploaded_ yamls to the website with `random`, should also now respect friendly limits without the need for `random-range` shenanigans.
  - _Uploaded_ yamls to the website, or yamls that are used to generate locally, that have hard-defined values outside the friendly limits, will be clamped/dragged/massaged into those limits (with logged warnings).
- Removed an early completion goal that was playing havoc with fill. Not enough people seem to use this goal, so its loss will not be mourned.
2025-05-30 16:31:00 +02:00
sgrunt
b0f41c0360 Timespinner: Fix Connection Logic from Maw Cave Entrance to Maw (#4831)
Co-authored-by: sgrunt <sgrunt1987@gmail.com>
2025-05-28 20:40:24 -04:00
sgrunt
6ebd60feaa Timespinner: Fix Logic Error with Risky Warp to Emperor's Tower and Lab Access (#4784)
Co-authored-by: sgrunt <sgrunt1987@gmail.com>
2025-05-28 20:37:39 -04:00
Jonathan Tan
dd6007b309 TWW: Remove unnecessary items from slot data (#5045) 2025-05-29 00:27:03 +02:00
Ehseezed
fde203379d Timespinner: Fix Logic (#4803)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-28 15:04:57 -04:00
LiquidCat64
fcb3efee01 CVCotM: Add Nerf Roc Wing to Slot Data and HoD Max Ups to other_game_item_appearances (#5051) 2025-05-28 10:47:24 -04:00
black-sliver
19a21099ed Webhost: update Flask to 3.1.1 (#5052) 2025-05-27 16:21:43 +00:00
Jonathan Tan
20ca7e71c7 TWW: Update patch class (#5046) 2025-05-27 07:57:20 +02:00
ScootyPuffJr1
002202ff5f Update OOT Guides (#5041)
* Update OOT Guides

* Minor update per review
2025-05-26 07:25:39 +00:00
FlitPix
32487137e8 Core: Add descriptions to Components (#4849)
* Add descriptions to components

* Adhere to style guide

* Tweak BHC wording

* Trim Open Patch description

* Update text client description for consistency

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

* Remove newlines

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-25 17:17:30 -04:00
LiquidCat64
f327ab30a6 CV64: Allow Holding Z to Use the Regular Shimmy Speed (#4730)
* Add the shimmy modifier hack.

* Update the Increase Shimmy Speed option description.

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-25 05:20:25 -04:00
agilbert1412
e7545cbc28 SDV: Fixed Region for two Parrot Locations (#5042) 2025-05-24 17:59:55 -04:00
NewSoupVi
eba757d2cd Raft: Implement get_filler_item_name and refactor filler item code a bit (#4782)
* refactor filler item creation for Raft, implement get_filler_item_name

* wrong indent

* Update worlds/raft/__init__.py

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-24 23:02:27 +02:00
Star Rauchenberger
4119763e23 Lingo: Fix The Bearer's Pilgrimage Logic (#5005) 2025-05-24 09:35:06 -04:00
Jonathan Tan
e830a6d6f5 TWW: Only add Filler for Excluded Locations Which are Progress Locations (#4993)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-24 09:17:54 -04:00
1181 changed files with 205186 additions and 38362 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

@@ -2,11 +2,15 @@
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/general/test_rule_builder.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py",
@@ -29,7 +33,7 @@
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.10",
"pythonVersion": "3.11",
"pythonPlatform": "Windows",
"executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: '3.10'
python-version: '3.11'
- name: "Install dependencies"
if: env.diff != ''

View File

@@ -1,4 +1,5 @@
# This workflow will build a release-like distribution when manually dispatched
# This workflow will build a release-like distribution when manually dispatched:
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
name: Build
@@ -9,17 +10,25 @@ on:
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch:
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.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
@@ -42,7 +51,7 @@ jobs:
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
choco install innosetup --version=6.7.0 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
@@ -98,7 +107,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
@@ -134,10 +143,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_FORK/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_FORK/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: |
@@ -189,7 +201,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4

154
.github/workflows/docker.yml vendored Normal file
View File

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

View File

@@ -6,11 +6,12 @@ on:
permissions:
contents: read
pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs:
labeler:
name: 'Apply content-based labels'
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5

View File

@@ -5,11 +5,17 @@ name: Release
on:
push:
tags:
- '*.*.*'
- 'v?[0-9]+.[0-9]+.[0-9]*'
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.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
@@ -122,10 +128,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_FORK/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_FORK/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'
@@ -33,15 +39,15 @@ jobs:
matrix:
os: [ubuntu-latest]
python:
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
- {version: '3.12'}
- {version: '3.13'}
include:
- python: {version: '3.10'} # old compat
- python: {version: '3.11'} # old compat
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.13'} # current
os: windows-latest
- python: {version: '3.12'} # current
- python: {version: '3.13'} # current
os: macos-latest
steps:
@@ -53,7 +59,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-subtests pytest-xdist
pip install -r ci-requirements.txt
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests
@@ -69,7 +75,7 @@ jobs:
os:
- ubuntu-latest
python:
- {version: '3.12'} # current
- {version: '3.13'} # current
steps:
- uses: actions/checkout@v4

10
.gitignore vendored
View File

@@ -56,7 +56,6 @@ success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
@@ -64,7 +63,10 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/datapackage_export.json
/custom_worlds
# stubgen output
/out/
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -184,12 +186,6 @@ _speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -11,6 +11,7 @@ from typing import List
import Utils
from settings import get_settings
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -80,8 +81,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"]
options = get_settings().adventure_options
self.display_msgs = options.display_msgs
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -102,7 +103,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_settings()["adventure_options"].get("death_link", False):
if get_settings().adventure_options.as_dict().get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -406,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
@@ -415,8 +417,9 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
options = get_settings().adventure_options
auto_start = options.rom_start
rom_args = options.rom_args
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

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.abc import Collection, MutableSequence
from collections import Counter, deque, defaultdict
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -21,6 +22,7 @@ import Utils
if TYPE_CHECKING:
from entrance_rando import ERPlacementState
from rule_builder.rules import Rule
from worlds import AutoWorld
@@ -84,7 +86,7 @@ class MultiWorld():
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]]
completion_condition: Dict[int, CollectionRule]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations]
@@ -153,17 +155,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 +178,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 +223,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)
@@ -275,6 +262,7 @@ class MultiWorld():
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
"skip_if_solo": item_link.get("skip_if_solo", False),
}
for _name, item_link in item_links.items():
@@ -298,6 +286,8 @@ class MultiWorld():
for group_name, item_link in item_links.items():
game = item_link["game"]
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
continue
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
@@ -438,12 +428,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 +461,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 +574,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 +692,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}")
@@ -775,7 +767,7 @@ class CollectionState():
else:
self._update_reachable_regions_auto_indirect_conditions(player, queue)
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items
@@ -793,13 +785,16 @@ class CollectionState():
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
self.multiworld.worlds[player].reached_region(self, new_region)
# Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
entrances = self.multiworld.indirect_connections.get(new_region)
if entrances is not None:
relevant_entrances = entrances.intersection(blocked_connections)
relevant_entrances.difference_update(queue)
queue.extend(relevant_entrances)
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
new_connection: bool = True
@@ -821,6 +816,7 @@ class CollectionState():
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
new_connection = True
self.multiworld.worlds[player].reached_region(self, new_region)
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections)
@@ -869,20 +865,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:
@@ -1065,13 +1174,17 @@ class CollectionState():
self.prog_items[player][item] = count
CollectionRule = Callable[[CollectionState], bool]
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
class EntranceType(IntEnum):
ONE_WAY = 1
TWO_WAY = 2
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
hide_path: bool = False
player: int
name: str
@@ -1150,13 +1263,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 +1283,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 +1295,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:
@@ -1242,8 +1355,7 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[type[Location]] = None) -> None:
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
@@ -1259,7 +1371,7 @@ class Region:
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
rule: CollectionRule | Rule[Any] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
@@ -1287,7 +1399,7 @@ class Region:
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
if rule is not None:
event_location.access_rule = rule
self.multiworld.worlds[self.player].set_rule(event_location, rule)
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
@@ -1298,7 +1410,7 @@ class Region:
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
"""
Connects this Region to another Region, placing the provided rule on the connection.
@@ -1306,8 +1418,8 @@ class Region:
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
if rule:
exit_.access_rule = rule
if rule is not None:
self.multiworld.worlds[self.player].set_rule(exit_, rule)
exit_.connect(connecting_region)
return exit_
@@ -1331,16 +1443,16 @@ class Region:
entrance.connect(self)
return entrance
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
"""
if not isinstance(exits, Dict):
if not isinstance(exits, Mapping):
exits = dict.fromkeys(exits)
return [
self.connect(
@@ -1371,7 +1483,7 @@ class Location:
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item: Optional[Item] = None
@@ -1430,31 +1542,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. """
progression_skip_balancing = 0b1001 # only progression gets balanced
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) """
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 +1626,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)
@@ -1598,9 +1730,10 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
if not multiworld.has_beaten_game(state):
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
"Something went terribly wrong here. "
f"Unreachable progression items: {sphere_candidates}")
else:
self.unreachables = sphere_candidates
break
@@ -1734,6 +1867,9 @@ class Spoiler:
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
if self.multiworld.players > 1:
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1742,6 +1878,9 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
outfile.write('Location Count: %d\n' % loc_count)
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
@@ -1778,7 +1917,8 @@ class Spoiler:
if self.unreachables:
outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
for unreachable in sorted(self.unreachables)]))
if self.paths:
outfile.write('\n\nPaths:\n\n')
@@ -1805,7 +1945,7 @@ class Tutorial(NamedTuple):
description: str
language: str
file_name: str
link: str
link: str # unused
authors: List[str]

169
CommonClient.py Normal file → Executable file
View File

@@ -21,10 +21,10 @@ 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
from Utils import gui_enabled, Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
import ssl
@@ -35,9 +35,6 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger("Client")
# without terminal, we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
@Utils.cache_argsless
def get_ssl_context():
@@ -65,6 +62,8 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_exit(self) -> bool:
"""Close connections and client"""
if self.ctx.ui:
self.ctx.ui.stop()
self.ctx.exit_event.set()
return True
@@ -107,7 +106,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.ctx.location_names[self.ctx.game]
for location_id, location in lookup.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -128,43 +129,87 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_items(self):
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
"""
Helper to digest a specific section of this game's 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.ctx.item_names if name == "Item Names" else self.ctx.location_names
lookup = lookup[self.ctx.game]
self.output(f"{name} for {self.ctx.game}")
for name in lookup.values():
self.output(name)
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 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 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 +219,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 +247,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 +257,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
@@ -275,7 +322,7 @@ class CommonContext:
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
"""Current available Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
@@ -378,6 +425,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
@@ -522,6 +571,10 @@ class CommonContext:
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def is_connection_change(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out connection changes."""
return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict):
logger.info(args["text"])
@@ -637,6 +690,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:
@@ -788,9 +859,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address)
if server_url.username:
ctx.username = server_url.username
ctx.username = urllib.parse.unquote(server_url.username)
if server_url.password:
ctx.password = server_url.password
ctx.password = urllib.parse.unquote(server_url.password)
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
@@ -937,6 +1008,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 +1094,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>=75,<81"
COPY _speedups.pyx .
COPY intset.h .
RUN cythonize -b -i _speedups.pyx
# Archipelago
FROM python:3.12-slim-bookworm 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" ]

108
Fill.py
View File

@@ -116,12 +116,23 @@ 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)
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
placed_item = location.item
if item_to_place == placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
@@ -130,9 +141,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 +362,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 +487,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 +535,50 @@ 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.
# allow_partial should only be set if there is deprioritized progression to fall back on.
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=bool(deprioritized_progression))
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
@@ -890,7 +965,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
block.force)
continue
worlds.add(world_name_lookup[listed_world])
@@ -923,9 +998,9 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
if isinstance(locations, str):
locations = [locations]
locations_from_groups: list[str] = []
resolved_locations: list[Location] = []
for target_player in worlds:
locations_from_groups: list[str] = []
world_locations = multiworld.get_unfilled_locations(target_player)
for group in multiworld.worlds[target_player].location_name_groups:
if group in locations:
@@ -937,13 +1012,16 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
count = block.count
if not count:
count = len(new_block.items)
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = len(new_block.items)
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse():
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
from settings import get_settings
settings = get_settings()
defaults = settings.generator
@@ -57,7 +57,7 @@ def mystery_argparse():
parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.")
args = parser.parse_args()
args = parser.parse_args(argv)
if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only")
@@ -68,7 +68,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
args.plando = PlandoOptions.from_option_string(args.plando)
return args
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
meta_weights = None
player_id = 1
player_files = {}
player_id: int = 1
player_files: dict[int, str] = {}
player_errors: list[str] = []
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
@@ -135,9 +135,13 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
logging.exception(f"Exception reading weights in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
@@ -152,6 +156,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
@@ -161,28 +169,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
f"{seed_name} Seed {seed} with plando: {args.plando}")
if not weights_cache:
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
args.outputname = seed_name
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
args.name = {}
if meta_weights:
for category_name, category_dict in meta_weights.items():
@@ -198,52 +197,95 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else:
yaml[category_name][key] = option
player_path_cache = {}
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
if args.sameoptions:
for fname, yamls in weights_cache.items():
try:
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
except Exception as e:
logging.exception(f"Exception reading settings in file {fname}")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
)
# Exit early here to avoid throwing the same errors again later
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
player_path_cache: dict[int, str] = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
erargs.player_options = {}
name_counter: Counter[str] = Counter()
args.player_options = {}
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
if not path:
player_errors.append(f'No weights specified for player {player}')
player += 1
continue
for doc_index, yaml in enumerate(weights_cache[path]):
name = yaml.get("name")
try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
# Invariant: settings_cache[path] and weights_cache[path] have the same length
cached = settings_cache[path]
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
# name was not specified
if player not in erargs.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
erargs.name[player] = f"Player{player}"
else:
# use the filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
for k, v in vars(settings_object).items():
if v is not None:
try:
getattr(args, k)[player] = v
except AttributeError:
setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1
except Exception as e:
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
f"(name: {args.name.get(player, name)})")
player_errors.append(
f"{len(player_errors) + 1}. "
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
# increment for each yaml document in the file
player += 1
return erargs, seed
if len(set(name.lower() for name in args.name.values())) != len(args.name):
player_errors.append(
f"{len(player_errors) + 1}. "
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
)
if player_errors:
errors = "\n\n".join(player_errors)
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
f"See logs for full tracebacks.\n\n{errors}")
return args, seed
def read_weights_yamls(path) -> tuple[Any, ...]:
@@ -320,7 +362,7 @@ class SafeFormatter(string.Formatter):
return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter):
def handle_name(name: str, player: int, name_counter: Counter[str]):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
@@ -351,7 +393,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
@@ -365,13 +409,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
@@ -394,6 +443,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
@@ -449,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
try:
if option_key in game_weights:
if not option.supports_weighting:
@@ -495,7 +546,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
games = requirements.get("game", {})
for game, version in games.items():
if game not in AutoWorldRegister.world_types:
continue
if not version:
raise Exception(f"Invalid version for game {game}: {version}.")
if isinstance(version, str):
version = {"min": version}
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints:

View File

@@ -1,9 +0,0 @@
if __name__ == '__main__':
import ModuleUpdate
ModuleUpdate.update()
import Utils
Utils.init_logging("KH1Client", exception_logger="Client")
from worlds.kh1.Client import launch
launch()

View File

@@ -1,8 +0,0 @@
import ModuleUpdate
import Utils
from worlds.kh2.Client import launch
ModuleUpdate.update()
if __name__ == '__main__':
Utils.init_logging("KH2Client", exception_logger="Client")
launch()

View File

@@ -11,6 +11,7 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse
import logging
import multiprocessing
import os
import shlex
import subprocess
import sys
@@ -30,6 +31,10 @@ import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
if __name__ == "__main__":
init_logging('Launcher')
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
@@ -41,13 +46,17 @@ def open_host_yaml():
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
@@ -70,12 +79,17 @@ def open_patch():
launch([*exe, file], component.cli)
def generate_yamls():
def generate_yamls(*args):
from Options import generate_yaml_templates
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
parser.add_argument("--skip_open_folder", action="store_true")
args = parser.parse_args(args)
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
open_folder(target)
if not args.skip_open_folder:
open_folder(target)
def browse_files():
@@ -92,7 +106,11 @@ def open_folder(folder_path):
return
if exe:
subprocess.Popen([exe, folder_path])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
@@ -104,14 +122,21 @@ def update_settings():
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Open host.yaml", func=open_host_yaml,
description="Open the host.yaml file to change settings for generation, games, and more."),
Component("Open Patch", func=open_patch,
description="Open a patch file, downloaded from the room page or provided by the host."),
Component("Generate Template Options", func=generate_yamls,
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
])
@@ -180,7 +205,8 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
subprocess.Popen(['start', *exe], shell=True)
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -196,12 +222,17 @@ def launch(exe, in_terminal=False):
def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut
script = sys.argv[0]
wkdir = Utils.local_path()
env = os.environ
if "APPIMAGE" in env:
script = env["ARGV0"]
wkdir = None # defaults to ~ on Linux
else:
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir)
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
button.menu.dismiss()
@@ -466,8 +497,7 @@ def main(args: argparse.Namespace | dict | None = None):
if __name__ == '__main__':
init_logging('Launcher')
Utils.freeze_support()
multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
parser = argparse.ArgumentParser(
description='Archipelago Launcher',

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,16 +286,14 @@ 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
async def run_game(romfile):
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
from worlds.mmbn3 import MMBN3World
auto_start = MMBN3World.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):

68
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
@@ -12,8 +13,9 @@ import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
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
@@ -35,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
multiworld.plando_options = args.plando
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -52,12 +54,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string():{version_count}} | "
f"Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
del item_count, location_count
@@ -92,6 +99,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")
@@ -99,12 +115,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:
@@ -125,11 +135,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)
@@ -173,7 +181,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.")
@@ -238,11 +246,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]
@@ -257,7 +267,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()
@@ -314,7 +326,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 = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -324,7 +336,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,
@@ -332,13 +344,17 @@ 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)
multidata = zlib.compress(pickle.dumps(multidata), 9)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
serialized_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
f.write(multidata)
f.write(serialized_multidata)
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():

View File

@@ -1,347 +0,0 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
from settings import get_settings
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options.java)
if not jdk_exe:
jdk_exe = shutil.which("java") # try to fall back to system java
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = get_settings().minecraft_options
channel = args.channel or options.release_channel
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options.forge_directory
max_heap = options.max_heap_size
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -5,18 +5,23 @@ import multiprocessing
import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
"Official 3.11.9 through 3.13.x is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 10, 1):
elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(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
@@ -70,11 +75,11 @@ def update_command():
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
except ImportError:
except (AttributeError, ImportError):
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
def update(yes: bool = False, force: bool = False) -> None:

View File

@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
import colorama
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
@@ -43,13 +43,22 @@ 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
min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
no_version = Version(0, 0, 0)
assert isinstance(no_version, tuple) # assert immutable
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
server_max_window_bits=11,
client_max_window_bits=11,
compress_settings={"memLevel": 4},
)
def remove_from_list(container, value):
try:
@@ -60,6 +69,12 @@ def remove_from_list(container, value):
def pop_from_container(container, value):
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
return container
if isinstance(container, dict) and value not in container:
return container
try:
container.pop(value)
except ValueError:
@@ -125,8 +140,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str]
__slots__ = (
"__weakref__",
"version",
"auth",
"team",
"slot",
"send_index",
"tags",
"messageprocessor",
"ctx",
"remote_items",
"remote_start_inventory",
"no_items",
"no_locations",
"no_text",
)
version: Version
auth: bool
team: int | None
slot: int | None
send_index: int
tags: list[str]
messageprocessor: ClientMessageProcessor
ctx: weakref.ref[Context]
remote_items: bool
remote_start_inventory: bool
no_items: bool
@@ -135,6 +173,7 @@ class Client(Endpoint):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket)
self.version = no_version
self.auth = False
self.team = None
self.slot = None
@@ -142,6 +181,11 @@ class Client(Endpoint):
self.tags = []
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
self.remote_items = False
self.remote_start_inventory = False
self.no_items = False
self.no_locations = False
self.no_text = False
@property
def items_handling(self):
@@ -179,6 +223,7 @@ class Context:
"release_mode": str,
"remaining_mode": str,
"collect_mode": str,
"countdown_mode": str,
"item_cheat": bool,
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
@@ -208,8 +253,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger
super(Context, self).__init__()
self.slot_info = {}
@@ -242,6 +287,7 @@ class Context:
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat
self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[
@@ -445,15 +491,16 @@ 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 = {}
# there might be a better place to put this.
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
race_mode = decoded_obj.get("race_mode", 0)
self.read_data["race_mode"] = lambda: race_mode
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
@@ -546,6 +593,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))
@@ -626,6 +674,7 @@ class Context:
"server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
@@ -660,6 +709,7 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
@@ -752,7 +802,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 +817,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]:
@@ -867,12 +918,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
async def on_client_connected(ctx: Context, client: Client):
players = []
for team, clients in ctx.clients.items():
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -1133,8 +1178,13 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
status: HintStatus | None = None) -> typing.List[Hint]:
"""
Collect a new hint for a given item id or name, with a given status.
If status is None (which is the default value), an automatic status will be determined from the item's quality.
"""
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -1150,25 +1200,39 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
hint_status = status # Assign again because we're in a for loop
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
hint_status = HintStatus.HINT_FOUND
elif hint_status is None:
if item_flags & ItemClassification.trap:
hint_status = HintStatus.HINT_AVOID
else:
hint_status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
)
return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
"""
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
If None is passed for the status, then an automatic status will be determined from the item's quality.
"""
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
@@ -1178,13 +1242,16 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
status = HintStatus.HINT_FOUND
elif status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
return []
@@ -1298,7 +1365,11 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
method_doc = inspect.getdoc(method)
if method_doc is None:
method_doc = "(missing help text)"
doctext = "\n ".join(method_doc.split("\n"))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s
def _cmd_help(self):
@@ -1327,19 +1398,6 @@ class CommandProcessor(metaclass=CommandMeta):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
@@ -1481,6 +1539,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
@@ -1608,7 +1683,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1634,9 +1708,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else:
game = self.ctx.games[self.client.slot]
@@ -1656,16 +1730,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
hints.extend(
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
)
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
else:
self.output(response)
@@ -1943,13 +2019,64 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
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}])
return
try:
status = HintStatus(status)
except ValueError as err:
await ctx.send_msgs(client,
[{"cmd": "InvalidPacket", "type": "arguments",
"text": f"Unknown Status: {err}",
"original_cmd": cmd}])
return
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"]
@@ -2184,6 +2311,19 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
@@ -2305,9 +2445,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
hints = collect_hints(self.ctx, team, slot, item)
if hints:
self.ctx.notify_hints(team, hints)
@@ -2341,17 +2481,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
hints = collect_hint_location_id(self.ctx, team, slot, location)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints:
self.ctx.notify_hints(team, hints)
else:
@@ -2379,6 +2516,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2466,6 +2608,13 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion
''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s)
@@ -2531,7 +2680,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.remaining_mode,
args.countdown_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
@@ -2566,7 +2715,13 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
host=ctx.host,
port=ctx.port,
ssl=ssl_context,
extensions=[server_per_message_deflate_factory],
)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))

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):
@@ -106,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
@@ -152,6 +174,8 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint:
__slots__ = ("socket",)
socket: "ServerConnection"
def __init__(self, socket):
@@ -450,6 +474,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

@@ -24,6 +24,39 @@ if typing.TYPE_CHECKING:
import pathlib
_RANDOM_OPTS = [
"random", "random-low", "random-middle", "random-high",
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
]
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
def random_weighted_range(text: str, range_start: int, range_end: int):
if text == "random-low":
return triangular(range_start, range_end, 0.0)
elif text == "random-high":
return triangular(range_start, range_end, 1.0)
elif text == "random-middle":
return triangular(range_start, range_end)
elif text == "random":
return random.randint(range_start, range_end)
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
@@ -417,10 +450,12 @@ class Toggle(NumericOption):
def from_text(cls, text: str) -> Toggle:
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
return cls(0)
else:
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
return cls(1)
else:
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
@classmethod
def from_any(cls, data: typing.Any):
@@ -494,14 +529,38 @@ 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__
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int]
value: str | int
def __init__(self, value: typing.Union[str, int]):
def __init__(self, value: str | int):
assert isinstance(value, str) or isinstance(value, int), \
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
@@ -522,7 +581,7 @@ class TextChoice(Choice):
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
def get_option_name(cls, value: str | int) -> str:
if isinstance(value, str):
return value
return super().get_option_name(value)
@@ -689,33 +748,39 @@ class Range(NumericOption):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
else: # "false"
return cls(0)
return cls(int(text))
# "false"
return cls(0)
try:
num = int(text)
except ValueError:
# text is not a number
# Handle conditionally acceptable values here rather than in the f-string
default = ""
truefalse = ""
if hasattr(cls, "default"):
default = ", default"
if cls.range_start == 0 and cls.default != 0:
truefalse = ", \"true\", \"false\""
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
f"<int>{default}, high, low{truefalse}, "
f"{', '.join(cls._RANDOM_OPTS)}.")
return cls(num)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
if text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
@classmethod
def custom_range(cls, text) -> Range:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
random_range.sort()
@@ -723,14 +788,9 @@ class Range(NumericOption):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
else:
return cls(random.randint(random_range[0], random_range[1]))
if textsplit[2] in ("low", "middle", "high"):
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
return cls(random_weighted_range("random", *random_range))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
@@ -745,18 +805,6 @@ class Range(NumericOption):
def __str__(self) -> str:
return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {}
@@ -846,7 +894,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
supports_weighting = False
@@ -861,17 +909,18 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
def get_option_name(self, value):
@classmethod
def get_option_name(cls, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
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:
@@ -941,7 +990,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
@classmethod
def get_option_name(cls, value):
return ", ".join(map(str, value))
def __contains__(self, item):
@@ -951,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
supports_weighting = False
random_str: str | None
def __init__(self, value: typing.Iterable[str]):
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
self.value = set(deepcopy(value))
self.random_str = random_str
super(OptionSet, self).__init__()
@classmethod
def from_text(cls, text: str):
check_text = text.lower().split(",")
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
and len(check_text) == 1 and check_text[0].startswith("random")):
return cls((), check_text[0])
return cls([option.strip() for option in text.split(",")])
@classmethod
@@ -966,7 +1022,37 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
if self.random_str and not self.value:
choice_list = sorted(self.valid_keys)
if self.verify_item_name:
choice_list.extend(sorted(world.item_names))
if self.verify_location_name:
choice_list.extend(sorted(world.location_names))
if self.random_str.startswith("random-range-"):
textsplit = self.random_str.split("-")
try:
random_range = [int(textsplit[-2]), int(textsplit[-1])]
except ValueError:
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
f"for player {player_name}")
random_range.sort()
if random_range[0] < 0 or random_range[1] > len(choice_list):
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
if textsplit[2] in ("low", "middle", "high"):
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
random_range[0], random_range[1])
else:
choice_count = random_weighted_range("random", random_range[0], random_range[1])
else:
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
self.value = set(random.sample(choice_list, k=choice_count))
super(Option, self).verify(world, player_name, plando_options)
@classmethod
def get_option_name(cls, value):
return ", ".join(sorted(value))
def __contains__(self, item):
@@ -994,6 +1080,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
supports_weighting = False
display_name = "Plando Texts"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@@ -1067,10 +1155,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 +1182,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
@@ -1120,6 +1208,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
@@ -1217,7 +1307,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 +1405,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:
@@ -1355,7 +1446,7 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict):
"""Start with these items."""
"""Start with the specified amount of these items. Example: "Bomb: 1" """
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
@@ -1363,7 +1454,7 @@ class StartInventory(ItemDict):
class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world.
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
The game decides what the replacement items will be.
"""
@@ -1410,6 +1501,7 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
rich_text_doc = True
default = []
schema = Schema([
@@ -1421,6 +1513,7 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
Optional("skip_if_solo"): Or(None, bool),
}
])
@@ -1448,8 +1541,10 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.")
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
f"You have more than one link named '{link['name']}'.")
existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
@@ -1491,6 +1586,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
default = ()
supports_weighting = False
display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
@@ -1524,9 +1620,11 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", ["Everywhere"])
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
@@ -1599,7 +1697,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1641,7 +1739,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
@@ -1685,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from Utils import local_path, __version__
full_path: str
preset_folder = os.path.join(target_folder, "Presets")
os.makedirs(target_folder, exist_ok=True)
os.makedirs(preset_folder, exist_ok=True)
# clean out old
for file in os.listdir(target_folder):
@@ -1694,18 +1794,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
for file in os.listdir(preset_folder):
full_path = os.path.join(preset_folder, file)
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
notes = {}
def dictify_range(option: Range, option_val: int | str):
data = {option_val: 50}
for sub_option in ["random", "random-low", "random-high",
f"random-range-{option.range_start}-{option.range_end}"]:
if sub_option != option_val:
data[sub_option] = 0
notes = {
"random-low": "random value weighted towards lower values",
"random-high": "random value weighted towards higher values",
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
f"{option.range_start} and {option.range_end}"
}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
data[name] = data[number]
del data[number]
elif name in data:
pass
else:
data[name] = 0
@@ -1721,17 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
presets = world.web.options_presets.copy()
presets.update({"": {}})
option_groups = get_option_groups(world)
res = template.render(
option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
for name, preset in presets.items():
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
preset_name=name,
preset=preset,
)
preset_name = f" - {name}" if name else ""
with open(os.path.join(preset_folder if name else target_folder,
get_file_safe_name(game_name + preset_name) + ".yaml"),
"w", encoding="utf-8-sig") as f:
f.write(res)
def dump_player_options(multiworld: MultiWorld) -> None:

696
OptionsCreator.py Normal file
View File

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

View File

@@ -7,7 +7,6 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
@@ -15,14 +14,12 @@ Currently, the following games are supported:
* Super Metroid
* Secret of Evermore
* Final Fantasy
* Rogue Legacy
* VVVVVV
* Raft
* Super Mario 64
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
@@ -42,7 +39,6 @@ Currently, the following games are supported:
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure
* DLC Quest
* Noita
@@ -83,6 +79,12 @@ Currently, the following games are supported:
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
* Celeste (Open World)
* Choo-Choo Charles
* APQuest
* Satisfactory
* EarthBound
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

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
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.get_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.get_settings().sni_options.snes_rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -1,11 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.sc2.Client import launch
import Utils
if __name__ == "__main__":
Utils.init_logging("Starcraft2Client", exception_logger="Client")
launch()

View File

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

378
Utils.py
View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import typing
import builtins
@@ -21,6 +22,8 @@ from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
@@ -35,7 +38,7 @@ if typing.TYPE_CHECKING:
def tuplize_version(version: str) -> Version:
return Version(*(int(piece, 10) for piece in version.split(".")))
return Version(*(int(piece) for piece in version.split(".")))
class Version(typing.NamedTuple):
@@ -47,7 +50,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.2"
__version__ = "0.6.7"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -166,6 +169,10 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -177,7 +184,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"):
pass
elif os.access(local_path(), os.W_OK):
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
@@ -226,7 +233,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -304,20 +316,19 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove when removing get_options
@deprecated("Utils.get_options() is deprecated. Use the settings API instead.")
def get_options() -> Settings:
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
return get_settings()
def persistent_store(category: str, key: str, value: typing.Any):
path = user_path("_persistent_storage.yaml")
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
storage = persistent_load()
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
return # no changes necessary
category_dict = storage.setdefault(category, {})
category_dict[key] = value
path = user_path("_persistent_storage.yaml")
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
@@ -379,6 +390,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
logging.debug(f"Could not store data package: {e}")
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
try:
with open(filename) as ignore_file:
return GitIgnoreSpec.from_lines(ignore_file)
except FileNotFoundError:
return None
def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
@@ -404,13 +423,26 @@ 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")
try:
with open(common_path) as f:
common_file = json.load(f)
uuid = common_file.get("uuid", None)
except FileNotFoundError:
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
cache_folder = os.path.dirname(common_path)
os.makedirs(cache_folder, exist_ok=True)
with open(common_path, "w") as f:
json.dump(common_file, f, separators=(",", ":"))
return uuid
@@ -433,6 +465,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
@@ -452,7 +485,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module)
obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)):
self.options_module.PlandoItem, self.options_module.PlandoText)):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -463,6 +496,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.
@@ -683,13 +728,22 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
"""
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
arguments with it.
:param text: The response text from `get_intended_text`.
:param command: The command to which the input text should be added. Must contain the prefix used by the command
(`!` or `/`).
:return: The command with the suggested input text appended, or None if no suggestion was found.
"""
if "did you mean " in text:
for question in ("Didn't find something that closely matches",
"Too many close matches"):
if text.startswith(question):
name = get_text_between(text, "did you mean '",
"'? (")
return f"!{command} {name}"
return f"{command} {name}"
elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ")
return None
@@ -708,25 +762,35 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -749,8 +813,62 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
try:
return tkinter.filedialog.askopenfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because save_filename was used for "{title}".')
raise e
else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
try:
return tkinter.filedialog.asksaveasfilename(
title=title,
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None,
)
finally:
root.destroy()
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
@@ -760,21 +878,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f"--title={title}", "--getexistingdirectory",
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -801,8 +916,12 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if not gui_enabled:
if error:
logging.error(f"{title}: {text}")
else:
logging.info(f"{title}: {text}")
return
if is_kivy_running():
from kvui import MessageBox
@@ -814,10 +933,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes
@@ -839,6 +958,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update()
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
@@ -869,7 +991,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
@@ -883,6 +1005,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
def deprecate(message: str, add_stacklevels: int = 0):
"""also use typing_extensions.deprecated wherever you use this"""
if __debug__:
raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels)
@@ -906,15 +1029,15 @@ class DeprecateDict(dict):
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
# original upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
from subprocess import _args_from_interpreter_flags
from subprocess import _args_from_interpreter_flags # noqa
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
@@ -922,8 +1045,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())
):
@@ -942,20 +1064,36 @@ def _extend_freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
if not is_windows and is_frozen():
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
def _noop() -> None:
pass
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
@deprecated("Use multiprocessing.freeze_support() instead")
def freeze_support() -> None:
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
import multiprocessing
_extend_freeze_support()
deprecate("Use multiprocessing.freeze_support() instead")
multiprocessing.freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
_extend_freeze_support()
def visualize_regions(
root_region: Region,
file_name: str,
*,
show_entrance_names: bool = False,
show_locations: bool = True,
show_other_regions: bool = True,
linetype_ortho: bool = True,
regions_to_highlight: set[Region] | None = None,
entrance_highlighting: dict[int, int] | None = None,
detail_other_regions: bool = False,
auto_assign_colors: bool = False) -> None:
"""Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -972,6 +1110,13 @@ def visualize_regions(root_region: Region, file_name: str, *,
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
your entrances
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
from root_region.
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
not found in the passed-in map
Example usage in World code:
from Utils import visualize_regions
@@ -997,6 +1142,34 @@ def visualize_regions(root_region: Region, file_name: str, *,
regions: typing.Deque[Region] = deque((root_region,))
multiworld: MultiWorld = root_region.multiworld
colors_used: set[int] = set()
if entrance_highlighting:
for color in entrance_highlighting.values():
# filter the colors to their most-significant bits to avoid too similar colors
colors_used.add(color & 0xF0F0F0)
else:
# assign an empty dict to not crash later
# the parameter is optional for ease of use when you don't care about colors
entrance_highlighting = {}
def select_color(group: int) -> int:
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
# obvious cyclical color patterns
COLOR_INDEX_SPACING: int = 0x357
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
while new_color in colors_used:
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
# and worlds are unlikely to get to anywhere close to that many entrance groups
# intentionally not using multiworld.random to not affect output when debugging with this tool
new_color_index += COLOR_INDEX_SPACING
new_color = ((new_color_index & 0xF00) << 12) + \
((new_color_index & 0xF0) << 8) + \
((new_color_index & 0xF) << 4)
return new_color
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
name = obj.name
if isinstance(obj, Item):
@@ -1016,18 +1189,28 @@ def visualize_regions(root_region: Region, file_name: str, *,
def visualize_exits(region: Region) -> None:
for exit_ in region.exits:
color_code: str = ""
if exit_.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
if exit_.connected_region:
if show_entrance_names:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
else:
try:
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
except ValueError:
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
else:
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
for entrance in region.entrances:
color_code: str = ""
if entrance.randomization_group in entrance_highlighting:
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
if not entrance.parent_region:
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
def visualize_locations(region: Region) -> None:
any_lock = any(location.locked for location in region.locations)
@@ -1048,9 +1231,27 @@ def visualize_regions(root_region: Region, file_name: str, *,
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
uml.append("package \"other regions\" <<Cloud>> {")
for region in other_regions:
uml.append(f"class \"{fmt(region)}\"")
if detail_other_regions:
visualize_region(region)
else:
uml.append(f"class \"{fmt(region)}\"")
uml.append("}")
if auto_assign_colors:
all_entrances: list[Entrance] = []
for region in multiworld.get_regions(root_region.player):
all_entrances.extend(region.entrances)
all_entrances.extend(region.exits)
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
for group in all_groups:
if group not in entrance_highlighting:
if len(colors_used) >= 0x1000:
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
break
new_color: int = select_color(group)
entrance_highlighting[group] = new_color
colors_used.add(new_color)
uml.append("@startuml")
uml.append("hide circle")
uml.append("hide empty members")
@@ -1061,7 +1262,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
seen.add(current_region)
visualize_region(current_region)
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
if show_other_regions:
if show_other_regions or detail_other_regions:
visualize_other_regions()
uml.append("@enduml")
@@ -1088,3 +1289,72 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str):
return False
return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown
def get_full_typename(t: type) -> str:
"""Returns the full qualified name of a type, including its module (if not builtins)."""
module = t.__module__
if module and module != "builtins":
return f"{module}.{t.__qualname__}"
return t.__qualname__
def get_all_causes(ex: Exception) -> str:
"""Return a string describing the recursive causes of this exception.
:param ex: The exception to be described.
:return A multiline string starting with the initial exception on the first line and each resulting exception
on subsequent lines with progressive indentation.
For example:
```
Exception: Invalid value 'bad'.
Which caused: Options.OptionError: Error generating option
Which caused: ValueError: File bad.yaml is invalid.
```
"""
cause = ex
causes = [f"{get_full_typename(type(ex))}: {ex}"]
while cause := cause.__cause__:
causes.append(f"{get_full_typename(type(cause))}: {cause}")
top = causes[-1]
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
return f"{top}{others}"

View File

@@ -20,7 +20,8 @@ if typing.TYPE_CHECKING:
Utils.local_path.cached_path = os.path.dirname(__file__)
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
if not os.path.exists(configpath):
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
configpath = os.path.abspath(Utils.user_path('config.yaml'))
@@ -54,16 +55,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 +72,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 +85,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__":
@@ -131,18 +100,25 @@ if __name__ == "__main__":
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files
try:
from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
from worlds import AutoWorldRegister
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
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

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

View File

@@ -1,6 +1,7 @@
import base64
import os
import socket
import typing
import uuid
from flask import Flask
@@ -22,6 +23,17 @@ app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
# overwrites of flask default config
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["SESSION_PERMANENT"] = True
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
# custom config
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
@@ -29,19 +41,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
# archipelago.gg uses gunicorn + nginx; ignoring this option
@@ -61,30 +66,44 @@ cache = Cache()
Compress(app)
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value: uuid.UUID) -> str:
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 + '=='))
def to_python(self, value: str) -> uuid.UUID:
return to_python(value)
def to_url(self, value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
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():
def register() -> None:
"""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

@@ -2,14 +2,24 @@
from typing import List, Tuple
from flask import Blueprint
from flask_cors import CORS
from ..models import Seed, Slot
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
cors = CORS(api_endpoints, resources={
r"/api/datapackage/*": {"origins": "*"},
r"/api/datapackage": {"origins": "*"},
r"/api/datapackage_checksum/*": {"origins": "*"},
r"/api/room_status/*": {"origins": "*"},
r"/api/tracker/*": {"origins": "*"},
r"/api/static_tracker/*": {"origins": "*"},
r"/api/slot_data_tracker/*": {"origins": "*"}
})
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
from . import datapackage, generate, room, user # trigger registration
# trigger endpoint registration
from . import datapackage, generate, room, tracker, user

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,

258
WebHostLib/api/tracker.py Normal file
View File

@@ -0,0 +1,258 @@
from datetime import datetime, timezone
from typing import Any, TypedDict
from uuid import UUID
from flask import abort
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
from WebHostLib import cache
from WebHostLib.api import api_endpoints
from WebHostLib.models import Room
from WebHostLib.tracker import TrackerData
class PlayerAlias(TypedDict):
team: int
player: int
alias: str | None
class PlayerItemsReceived(TypedDict):
team: int
player: int
items: list[NetworkItem]
class PlayerChecksDone(TypedDict):
team: int
player: int
locations: list[int]
class TeamTotalChecks(TypedDict):
team: int
checks_done: int
class PlayerHints(TypedDict):
team: int
player: int
hints: list[Hint]
class PlayerTimer(TypedDict):
team: int
player: int
time: datetime | None
class PlayerStatus(TypedDict):
team: int
player: int
status: ClientStatus
class PlayerLocationsTotal(TypedDict):
team: int
player: int
total_locations: int
class PlayerGame(TypedDict):
team: int
player: int
game: str
@api_endpoints.route("/tracker/<suuid:tracker>")
@cache.memoize(timeout=60)
def tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
player_aliases: list[PlayerAlias] = []
"""Slot aliases of all players."""
for team, players in all_players.items():
for player in players:
player_aliases.append(
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
player_items_received: list[PlayerItemsReceived] = []
"""Items received by each player."""
for team, players in all_players.items():
for player in players:
player_items_received.append(
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
player_checks_done: list[PlayerChecksDone] = []
"""ID of all locations checked by each player."""
for team, players in all_players.items():
for player in players:
player_checks_done.append(
{"team": team, "player": player,
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
total_checks_done: list[TeamTotalChecks] = [
{"team": team, "checks_done": checks_done}
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
]
"""Total number of locations checked for the entire multiworld per team."""
hints: list[PlayerHints] = []
"""Hints that all players have used or received."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
player_hints = sorted(tracker_data.get_player_hints(team, player))
hints.append({"team": team, "player": player, "hints": player_hints})
slot_info = tracker_data.get_slot_info(player)
# this assumes groups are always after players
if slot_info.type != SlotType.group:
continue
for member in slot_info.group_members:
hints[member - 1]["hints"] += player_hints
activity_timers: list[PlayerTimer] = []
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
activity_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
for entry in activity_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
connection_timers: list[PlayerTimer] = []
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
for team, players in all_players.items():
for player in players:
connection_timers.append({"team": team, "player": player, "time": None})
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
# find the matching entry
for entry in connection_timers:
if entry["team"] == team and entry["player"] == player:
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
break
player_status: list[PlayerStatus] = []
"""The current client status for each player."""
for team, players in all_players.items():
for player in players:
player_status.append(
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
return {
"aliases": player_aliases,
"player_items_received": player_items_received,
"player_checks_done": player_checks_done,
"total_checks_done": total_checks_done,
"hints": hints,
"activity_timers": activity_timers,
"connection_timers": connection_timers,
"player_status": player_status,
}
class PlayerGroups(TypedDict):
slot: int
name: str
members: list[int]
class PlayerSlotData(TypedDict):
player: int
slot_data: dict[str, Any]
@api_endpoints.route("/static_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
"""
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
groups: list[PlayerGroups] = []
"""The Slot ID of groups and the IDs of the group's members."""
for team, players in tracker_data.get_all_slots().items():
for player in players:
slot_info = tracker_data.get_slot_info(player)
if slot_info.type != SlotType.group or not slot_info.group_members:
continue
groups.append(
{
"slot": player,
"name": slot_info.name,
"members": list(slot_info.group_members),
})
break
player_locations_total: list[PlayerLocationsTotal] = []
for team, players in all_players.items():
for player in players:
player_locations_total.append(
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
player_game: list[PlayerGame] = []
"""The played game per player slot."""
for team, players in all_players.items():
for player in players:
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
"player_game": player_game,
}
# It should be exceedingly rare that slot data is needed, so it's separated out.
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
@cache.memoize(timeout=300)
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
"""
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
:param tracker: UUID of current session tracker.
:return: Slot data for all players in the room. Typing completely arbitrary per game.
"""
room: Room | None = Room.get(tracker=tracker)
if not room:
abort(404)
tracker_data = TrackerData(room)
all_players: dict[int, list[int]] = tracker_data.get_all_players()
slot_data: list[PlayerSlotData] = []
"""Slot data for each player."""
for team, players in all_players.items():
for player in players:
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
break
return slot_data

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

@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def stop():
def stop() -> None:
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
def _mp_gen_game(
gen_options: dict,
meta: dict[str, Any] | None = None,
owner=None,
sid=None,
timeout: int|None = None,
) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
setproctitle(f"Generator (idle)")
return res
try:
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
finally:
setproctitle(f"Generator (idle)")
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(_mp_gen_game, (options,),
{"meta": meta,
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
pool.apply_async(
_mp_gen_game,
(options,),
{
"meta": meta,
"sid": generation.id,
"owner": generation.owner,
"timeout": timeout,
},
handle_generation_success,
handle_generation_failure,
)
except Exception as e:
generation.state = STATE_ERROR
commit()
@@ -135,6 +149,7 @@ def autogen(config: dict):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool:
job_time = config["JOB_TIME"]
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -145,7 +160,7 @@ def autogen(config: dict):
if sid:
generation.delete()
else:
launch_generator(generator_pool, generation)
launch_generator(generator_pool, generation, timeout=job_time)
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
@@ -157,16 +172,13 @@ def autogen(config: dict):
generation for generation in Generation
if generation.state == STATE_QUEUED).for_update()
for generation in to_start:
launch_generator(generator_pool, generation)
launch_generator(generator_pool, generation, timeout=job_time)
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
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

@@ -19,7 +19,10 @@ from pony.orm import commit, db_session, select
import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from MultiServer import (
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless
from .locker import Locker
from .models import Command, GameDataPackage, Room, db
@@ -86,18 +89,24 @@ class WebHostContext(Context):
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self):
async def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
while not self.exit_event.is_set():
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
time.sleep(5)
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
try:
await asyncio.wait_for(self.exit_event.wait(), 5)
except asyncio.TimeoutError:
pass
def _process_db_commands(self, cmdprocessor):
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id)
if commands:
for command in commands:
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
@db_session
def load(self, room_id: int):
@@ -129,7 +138,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']}")
@@ -146,19 +155,20 @@ class WebHostContext(Context):
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
with db_session:
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(savegame_data))
self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
asyncio.create_task(self.listen_to_db_commands())
@db_session
def _save(self, exit_save: bool = False) -> bool:
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
@@ -224,6 +234,17 @@ def set_up_logging(room_id) -> logging.Logger:
return logger
def tear_down_logging(room_id):
"""Close logging handling for a room."""
logger_name = f"RoomLogger {room_id}"
if logger_name in logging.Logger.manager.loggerDict:
logger = logging.getLogger(logger_name)
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
del logging.Logger.manager.loggerDict[logger_name]
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
@@ -281,8 +302,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
@@ -303,6 +328,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
del room
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
@@ -315,28 +341,36 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
ctx._save(True)
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
del room
logger.exception(e)
raise
else:
if ctx.saving:
ctx._save()
ctx._save(True)
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
if ctx.server and hasattr(ctx.server, "ws_server"):
ctx.server.ws_server.close()
await ctx.server.ws_server.wait_closed()
with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
del room
tear_down_logging(room_id)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)

View File

@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
else:
import io
if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
if slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):

View File

@@ -1,30 +1,29 @@
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
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain
from Utils import __version__
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options
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)
@@ -34,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)),
}
@@ -73,7 +73,11 @@ 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 format_exception(e: BaseException) -> str:
return f"{e.__class__.__name__}: {e}"
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,30 +87,40 @@ 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)
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
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, timeout: int|None = None):
if meta is None:
meta = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)
@@ -123,43 +137,47 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.spoiler_only = False
erargs.csv_output = False
args = mystery_argparse([]) # Just to set up the Namespace with defaults
args.multi = playercount
args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
args.spoiler = meta["generator_options"].get("spoiler", 0)
args.race = race
args.outputname = seedname
args.outputpath = target.name
args.teams = 1
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
args.skip_prog_balancing = False
args.skip_output = False
args.spoiler_only = False
args.csv_output = False
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items():
if v is not None:
if hasattr(erargs, k):
getattr(erargs, k)[player] = v
if hasattr(args, k):
getattr(args, k)[player] = v
else:
setattr(erargs, k, {player: v})
setattr(args, k, {player: v})
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta["server_options"])
if not args.name[player]:
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
if len(set(args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(app.config["JOB_TIME"])
return thread.result(timeout)
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
@@ -167,11 +185,14 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
meta["error"] = ("Allowed time for Generation exceeded, " +
"please consider generating locally instead. " +
format_exception(e))
gen.meta = json.dumps(meta)
commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e:
if sid:
with db_session:
@@ -179,10 +200,15 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": " + str(e))
meta["error"] = format_exception(e)
gen.meta = json.dumps(meta)
commit()
raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>')
@@ -196,7 +222,9 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return render_template("seedError.html", seed_error=generation.meta)
meta = json.loads(generation.meta)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("waitSeed.html", seed_id=seed_id)

View File

@@ -3,10 +3,10 @@ import threading
import json
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from worlds.alttp.Rom import Sprite
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
@@ -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)

90
WebHostLib/markdown.py Normal file
View File

@@ -0,0 +1,90 @@
import re
from collections import Counter
import mistune
from werkzeug.utils import secure_filename
__all__ = [
"ImgUrlRewriteInlineParser",
'render_markdown',
]
class ImgUrlRewriteInlineParser(mistune.InlineParser):
relative_url_base: str
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
super().__init__(hard_wrap)
self.relative_url_base = relative_url_base
@staticmethod
def _find_game_name_by_folder_name(name: str) -> str | None:
from worlds.AutoWorld import AutoWorldRegister
for world_name, world_type in AutoWorldRegister.world_types.items():
if world_type.__module__ == f"worlds.{name}":
return world_name
return None
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
res = super().parse_link(m, state)
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
image_token = state.tokens[-1]
url: str = image_token["attrs"]["url"]
if not url.startswith("/") and not "://" in url:
# replace relative URL to another world's doc folder with the webhost folder layout
if url.startswith("../../") and "/docs/" in self.relative_url_base:
parts = url.split("/", 4)
if parts[2] != ".." and parts[3] == "docs":
game_name = self._find_game_name_by_folder_name(parts[2])
if game_name is not None:
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
# change relative URL to point to deployment folder
url = f"{self.relative_url_base}/{url}"
image_token['attrs']['url'] = url
return res
def render_markdown(path: str, img_url_base: str | None = None) -> str:
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
# 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)
if img_url_base:
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
html = markdown(document)
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
return html

View File

@@ -1,5 +1,7 @@
import datetime
import os
import warnings
from enum import StrEnum
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions
@@ -7,15 +9,39 @@ 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 .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
class WebWorldTheme(StrEnum):
DIRT = "dirt"
GRASS = "grass"
GRASS_FLOWERS = "grassFlowers"
ICE = "ice"
JUNGLE = "jungle"
OCEAN = "ocean"
PARTY_TIME = "partyTime"
STONE = "stone"
def get_world_theme(game_name: str) -> str:
if game_name not in AutoWorldRegister.world_types:
return "grass"
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
available_themes = [theme.value for theme in WebWorldTheme]
if chosen_theme not in available_themes:
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
return "grass"
return chosen_theme
def get_world_theme(game_name: 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
@app.errorhandler(404)
@@ -31,83 +57,106 @@ 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)
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
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>')
@cache.cached()
def tutorial(game: str, file: str):
try:
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
return render_template(
"markdown_document.html",
title=f"{game} Guide",
html_from_markdown=document,
theme=theme,
)
except FileNotFoundError:
return abort(404)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
def tutorial(game, file, lang):
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:
return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
def tutorial_redirect(game: str, file: str, lang: str):
"""
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
"""
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
@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
}
worlds = dict(
title_sorted(
worlds.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game
)
)
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@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,
)
@@ -188,7 +237,10 @@ def host_room(room: UUID):
# indicate that the page should reload to get the assigned port
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
with db_session:
if now - room.last_activity > datetime.timedelta(minutes=1):
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
room.last_activity = now # will trigger a spinup, if it's not already running
browser_tokens = "Mozilla", "Chrome", "Safari"
@@ -196,9 +248,9 @@ def host_room(room: UUID):
or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> str:
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
if max_size == 0:
return ""
return "", 0
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
@@ -209,9 +261,9 @@ def host_room(room: UUID):
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
return "".join(fragments), raw_size
except FileNotFoundError:
return ""
return "", 0
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)

View File

@@ -13,6 +13,7 @@ from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta
from .misc import get_world_theme
def create() -> None:
@@ -22,12 +23,6 @@ def create() -> None:
Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
@@ -76,7 +71,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
return publish_parts(text, writer='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
@@ -155,7 +150,9 @@ def generate_weighted_yaml(game: str):
options = {}
for key, val in request.form.items():
if "||" not in key:
if val == "_ensure-empty-list":
options[key] = {}
elif "||" not in key:
if len(str(val)) == 0:
continue
@@ -212,8 +209,11 @@ def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if key in options:
if val == "_ensure-empty-list":
options[key] = []
elif options.get(key):
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
@@ -226,7 +226,7 @@ def generate_yaml(game: str):
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
if val != "0":
if val and val != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]

View File

@@ -1,12 +1,14 @@
flask>=3.1.0
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19
pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2
Flask-Caching>=2.3.0
Flask-Compress>=1.17
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12
Flask-Cors>=6.0.2
bokeh>=3.6.3
markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2

View File

@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](https://archipelago.gg/games).
Here is a list of our [Supported Games](/games).
## Can I generate a single-player game with Archipelago?
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
@@ -57,7 +57,7 @@ their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## I want to add a game to the Archipelago randomizer. How do I do that?
## I want to develop a game implementation for Archipelago. How do I do that?
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
@@ -77,4 +77,5 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
channel on our Discord.

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,49 +0,0 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -1,49 +1,43 @@
let updateSection = (sectionName, fakeDOM) => {
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
}
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Reload tracker every 60 seconds (sync'd)
const url = window.location;
// Note: This synchronization code is adapted from code in trackerCommon.js
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
console.log("Target second of refresh: " + targetSecond);
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
let getSleepTimeSeconds = () => {
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let category of categories) {
let hide_id = category.id.split('_')[0];
if (hide_id === 'Total') {
continue;
}
category.addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'_header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
let updateTracker = () => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update dynamic sections
updateSection('player-info', fakeDOM);
updateSection('section-filler', fakeDOM);
updateSection('section-terran', fakeDOM);
updateSection('section-zerg', fakeDOM);
updateSection('section-protoss', fakeDOM);
updateSection('section-nova', fakeDOM);
updateSection('section-kerrigan', fakeDOM);
updateSection('section-keys', fakeDOM);
updateSection('section-locations', fakeDOM);
};
ajax.open('GET', url);
ajax.send();
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
};
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
});

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

@@ -28,7 +28,6 @@
font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif;
text-transform: uppercase;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
text-shadow: 1px 1px 4px #000000;
}
@@ -37,7 +36,6 @@
font-size: 38px;
font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-top: 20px;
margin-bottom: 0.5rem;
@@ -50,7 +48,6 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
text-align: left;
cursor: pointer; /* TODO: remove once we drop showdown.js */
width: 100%;
margin-bottom: 0.5rem;
}
@@ -59,7 +56,6 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 24px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
margin-bottom: 24px;
}
@@ -67,14 +63,12 @@
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 22px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h6, .markdown details summary.h6{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 20px;
cursor: pointer; /* TODO: remove once we drop showdown.js */
}
.markdown h4, .markdown h5, .markdown h6{

View File

@@ -1,102 +0,0 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -1,160 +1,276 @@
#player-tracker-wrapper{
margin: 0;
*{
margin: 0;
font-family: "JuraBook", monospace;
}
body{
--icon-size: 36px;
--item-class-padding: 4px;
}
a{
color: #1ae;
}
#tracker-table td {
vertical-align: top;
/* Section colours */
#player-info{
background-color: #37a;
}
.player-tracker{
max-width: 100%;
}
.tracker-section{
background-color: grey;
}
#terran-items{
background-color: #3a7;
}
#zerg-items{
background-color: #d94;
}
#protoss-items{
background-color: #37a;
}
#nova-items{
background-color: #777;
}
#kerrigan-items{
background-color: #a37;
}
#keys{
background-color: #aa2;
}
.inventory-table-area{
border: 2px solid #000000;
border-radius: 4px;
padding: 3px 10px 3px 10px;
/* Sections */
.section-body{
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
padding-bottom: 3px;
}
.section-body-2{
display: flex;
flex-direction: column;
}
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
display: none;
}
.section-title{
position: relative;
border-bottom: 3px solid black;
/* Prevent text selection */
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
input[type="checkbox"]{
position: absolute;
cursor: pointer;
opacity: 0;
z-index: 1;
width: 100%;
height: 100%;
}
.section-title:hover h2{
text-shadow: 0 0 4px #ddd;
}
.f {
display: flex;
overflow: hidden;
}
.inventory-table-area:has(.inventory-table-terran) {
width: 690px;
background-color: #525494;
/* Acquire item filters */
.tracker-section img{
height: 100%;
width: var(--icon-size);
height: var(--icon-size);
background-color: black;
}
.unacquired, .lvl-0 .f{
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
}
.spacer{
width: var(--icon-size);
height: var(--icon-size);
}
.inventory-table-area:has(.inventory-table-zerg) {
width: 360px;
background-color: #9d60d2;
/* Item groups */
.item-class{
display: flex;
flex-flow: column;
justify-content: center;
padding: var(--item-class-padding);
}
.item-class-header{
display: flex;
flex-flow: row;
}
.item-class-upgrades{
/* Note: {display: flex; flex-flow: column wrap} */
/* just breaks on Firefox (width does not scale to content) */
display: grid;
grid-template-rows: repeat(4, auto);
grid-auto-flow: column;
}
.inventory-table-area:has(.inventory-table-protoss) {
width: 400px;
background-color: #d2b260;
/* Subsections */
.section-toc{
display: flex;
flex-direction: row;
}
.toc-box{
position: relative;
padding-left: 15px;
padding-right: 15px;
}
.toc-box:hover{
text-shadow: 0 0 7px white;
}
.ss-header{
position: relative;
text-align: center;
writing-mode: sideways-lr;
user-select: none;
padding-top: 5px;
font-size: 115%;
}
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
display: none;
}
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
display: none;
}
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
display: none;
}
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
display: none;
}
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
display: none;
}
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
display: none;
}
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
display: none;
}
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
background-color: #fff5;
box-shadow: 0 0 1px 1px white;
}
#tracker-table .inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
/* Progressive items */
.progressive{
max-height: var(--icon-size);
display: contents;
}
.inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
.lvl-0 > :nth-child(2),
.lvl-0 > :nth-child(3),
.lvl-0 > :nth-child(4),
.lvl-0 > :nth-child(5){
display: none;
}
.lvl-1 > :nth-child(2),
.lvl-1 > :nth-child(3),
.lvl-1 > :nth-child(4),
.lvl-1 > :nth-child(5){
display: none;
}
.lvl-2 > :nth-child(1),
.lvl-2 > :nth-child(3),
.lvl-2 > :nth-child(4),
.lvl-2 > :nth-child(5){
display: none;
}
.lvl-3 > :nth-child(1),
.lvl-3 > :nth-child(2),
.lvl-3 > :nth-child(4),
.lvl-3 > :nth-child(5){
display: none;
}
.lvl-4 > :nth-child(1),
.lvl-4 > :nth-child(2),
.lvl-4 > :nth-child(3),
.lvl-4 > :nth-child(5){
display: none;
}
.lvl-5 > :nth-child(1),
.lvl-5 > :nth-child(2),
.lvl-5 > :nth-child(3),
.lvl-5 > :nth-child(4){
display: none;
}
.inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
background-color: black;
/* Filler item counters */
.item-counter{
display: table;
text-align: center;
padding: var(--item-class-padding);
}
.item-count{
display: table-cell;
vertical-align: middle;
padding-left: 3px;
padding-right: 15px;
}
.inventory-table img.acquired{
filter: none;
background-color: black;
/* Hidden items */
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
display: none;
}
.inventory-table .tint-terran img.acquired {
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
/* Keys */
#keys ol, #keys ul{
columns: 3;
-webkit-columns: 3;
-moz-columns: 3;
}
#keys li{
padding-right: 15pt;
}
.inventory-table .tint-protoss img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
/* Locations */
#section-locations{
padding-left: 5px;
}
@media only screen and (min-width: 120ch){
#section-locations ul{
columns: 2;
-webkit-columns: 2;
-moz-columns: 2;
}
}
#locations li.checked{
list-style-type: "✔ ";
}
.inventory-table .tint-level-1 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
}
.inventory-table .tint-level-2 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
}
.inventory-table .tint-level-3 img.acquired {
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
}
.inventory-table div.counted-item {
position: relative;
}
.inventory-table div.item-count {
width: 160px;
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
border: 2px solid #000000;
border-radius: 4px;
background-color: #87b678;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
cursor: default;
}
#location-table table{
width: 100%;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
#location-table td:has(.location-column) {
vertical-align: top;
}
#location-table .location-column {
width: 100%;
height: 100%;
}
#location-table .location-column .spacer {
min-height: 24px;
}
.hide {
display: none;
}
/* Allowing scrolling down a little further */
.bottom-padding{
min-height: 33vh;
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,3 +72,13 @@ code{
padding-right: 0.25rem;
color: #000000;
}
code.grassy {
background-color: #b5e9a4;
border: 1px solid #2a6c2f;
white-space: preserve;
text-align: left;
display: block;
font-size: 14px;
line-height: 20px;
}

View File

@@ -13,3 +13,7 @@
min-height: 360px;
text-align: center;
}
h2, h4 {
color: #ffffff;
}

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

@@ -98,7 +98,7 @@
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
{% elif get_slot_info(hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
@@ -109,7 +109,7 @@
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
{% elif get_slot_info(hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">

View File

@@ -58,8 +58,7 @@
Open Log File...
</a>
</div>
{% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
{% set log, log_len = get_log() -%}
<div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}';

View File

@@ -26,15 +26,15 @@
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}
{% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% 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

@@ -45,15 +45,15 @@
{%- set current_sphere = loop.index %}
{%- for player, sphere_location_ids in sphere.items() %}
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(team, player) %}
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
{%- set finder_game = tracker_data.get_player_game(player) %}
{%- set player_location_data = tracker_data.get_player_locations(player) %}
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
<tr>
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
<td>{{ current_sphere }}</td>
<td>{{ tracker_data.get_player_name(team, player) }}</td>
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
<td>{{ tracker_data.get_player_name(player) }}</td>
<td>{{ tracker_data.get_player_name(receiver) }}</td>
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
<td>{{ finder_game }}</td>

View File

@@ -22,14 +22,14 @@
-%}
<tr>
<td>
{% if get_slot_info(team, hint.finding_player).type == 2 %}
{% if get_slot_info(hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
{% if get_slot_info(hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}

View File

@@ -55,6 +55,9 @@
{{ OptionTitle(option_name, option) }}
<div class="named-range-container">
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.special_range_names.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for key, val in option.special_range_names.items() %}
{% if option.default == val %}
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
@@ -94,6 +97,9 @@
<div class="text-choice-container">
<div class="text-choice-wrapper">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default not in option.options.values() %}
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
{% endif %}
{% for id, name in option.name_lookup.items()|sort %}
{% if name != "random" %}
{% if option.default == id %}
@@ -134,6 +140,7 @@
{% macro OptionList(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
@@ -146,6 +153,7 @@
{% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -169,6 +177,7 @@
{% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -192,6 +201,7 @@
{% macro OptionSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">

View File

@@ -4,16 +4,20 @@
{% block head %}
<title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
{% endblock %}
{% block body %}
{% include 'header/oceanIslandHeader.html' %}
<div id="wait-seed-wrapper" class="grass-island">
<div id="wait-seed">
<h1>Generation failed</h1>
<h2>please retry</h2>
{{ seed_error }}
<h1>Generation Failed</h1>
<h2>Please try again!</h2>
<p>{{ seed_error }}</p>
<h4>More details:</h4>
<p>
<code class="grassy">{{ details }}</code>
</p>
</div>
</div>
{% endblock %}

View File

@@ -11,32 +11,32 @@
<h1>Site Map</h1>
<h2>Base Pages</h2>
<ul>
<li><a href="/discord">Discord Link</a></li>
<li><a href="/faq/en">F.A.Q. Page</a></li>
<li><a href="/favicon.ico">Favicon</a></li>
<li><a href="/generate">Generate Game Page</a></li>
<li><a href="/">Homepage</a></li>
<li><a href="/uploads">Host Game Page</a></li>
<li><a href="/datapackage">Raw Data Package</a></li>
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
<li><a href="/sitemap">Site Map</a></li>
<li><a href="/start-playing">Start Playing</a></li>
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
</ul>
<h2>Tutorials</h2>
<ul>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
</ul>
<h2>Game Info Pages</h2>

View File

@@ -31,6 +31,9 @@
{% include 'header/oceanHeader.html' %}
<div id="games" class="markdown">
<h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">

View File

@@ -1,84 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

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

@@ -139,6 +139,7 @@
{% endmacro %}
{% macro OptionList(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="list-entry">
@@ -158,6 +159,7 @@
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
@@ -180,6 +182,7 @@
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
@@ -202,6 +205,7 @@
{% endmacro %}
{% macro OptionSet(option_name, option) %}
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="set-entry">

File diff suppressed because it is too large Load Diff

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
@@ -119,9 +117,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# AP Container
elif handler:
data = zfile.open(file, "r").read()
patch = handler(BytesIO(data))
patch.read()
files[patch.player] = data
with zipfile.ZipFile(BytesIO(data)) as container:
player = json.loads(container.open("archipelago.json").read())["player"]
files[player] = data
# Spoiler
elif file.filename.endswith(".txt"):
@@ -135,11 +133,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio
elif file.filename.endswith(".zip"):

View File

@@ -20,6 +20,8 @@ from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
from settings import get_settings
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
@@ -287,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
@@ -333,6 +335,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
@@ -340,13 +343,12 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("ZeldaClient")
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["tloz_options"].get("rom_start", True))
get_settings()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

2
ci-requirements.txt Normal file
View File

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

13
data/GLOBAL.apignore Normal file
View File

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

View File

@@ -24,9 +24,20 @@
<BaseButton>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<MDTabsItemBase>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<MDNavigationItemBase>:
on_release: app.screens.switch_screens(self)
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>:
adaptive_height: True
theme_font_size: "Custom"
@@ -209,8 +220,11 @@
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<MessageBox>:
height: self.content.texture_size[1] + 80
<ScrollBox>:
layout: layout
box_height: dp(100)
bar_width: "12dp"
scroll_wheel_distance: 40
do_scroll_x: False
@@ -221,9 +235,11 @@
orientation: "vertical"
spacing: 10
size_hint_y: None
height: self.minimum_height
height: max(self.minimum_height, root.box_height)
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then
server:settimeout(2)
server:settimeout(120)
local client, timeout = server:accept()
if timeout == nil then
print('Initial Connection Made')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

174
data/optionscreator.kv Normal file
View File

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

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,7 +0,0 @@
author: Nintendo
data: null
game: A Link to the Past
min_format_version: 1
name: Link
format_version: 1
sprite_version: 1

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,14 @@
# 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
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
# the proprietary assets in WebHostLib
#ASSET_RIGHTS: false

View File

@@ -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

@@ -15,15 +15,16 @@
# A Link to the Past
/worlds/alttp/ @Berserker66
# APQuest
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
# Blasphemous
/worlds/blasphemous/ @TRPG0
@@ -42,15 +43,18 @@
# Celeste 64
/worlds/celeste64/ @PoryGone
# Celeste (Open World)
/worlds/celeste_open_world/ @PoryGone
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
# Choo-Choo Charles
/worlds/cccharles/ @Yaranorgoth
# Civilization VI
/worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
@@ -66,12 +70,18 @@
# DOOM II
/worlds/doom_ii/ @Daivuk @KScl
# EarthBound
/worlds/earthbound/ @PinkSwitch
# Factorio
/worlds/factorio/ @Berserker66
# Faxanadu
/worlds/faxanadu/ @Daivuk
# Final Fantasy (1)
/worlds/ff1/ @Rosalie-A
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
@@ -121,9 +131,6 @@
# The Messenger
/worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
@@ -142,6 +149,9 @@
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
# Paint
/worlds/paint/ @MarioManTAW
# Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe
@@ -151,9 +161,6 @@
# Raft
/worlds/raft/ @SunnyBat
# Rogue Legacy
/worlds/rogue_legacy/ @ThePhar
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
@@ -172,8 +179,12 @@
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
# Satisfactory
/worlds/satisfactory/ @Jarno458 @budak7273
# Starcraft 2
/worlds/sc2/ @Ziktofel
# Note: @Ziktofel acts as a mentor
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
# Super Metroid
/worlds/sm/ @lordlou
@@ -206,7 +217,7 @@
/worlds/timespinner/ @Jarno458
# The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
/worlds/tloz/ @Rosalie-A
# TUNIC
/worlds/tunic/ @silent-destroyer @ScipioWright
@@ -244,9 +255,6 @@
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
# /worlds/ff1/
# Ocarina of Time
# /worlds/oot/

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