Compare commits

...

537 Commits

Author SHA1 Message Date
NewSoupVi
cd46f9acd9 oop 2025-10-25 22:51:14 +02:00
NewSoupVi
a07ff112d0 WebHost: Pin Flask-Compress to 1.18 for all versions of Python 2025-10-25 22:49:15 +02: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
Bryce Wilson
704cd97f21 BizHawkClient: Fix script to list all cores instead of explicit mapping (#5033) 2025-05-24 07:33:01 +02:00
agilbert1412
47a0dd696f Stardew Valley: Added moss to statue of blessings recipe (#5038) 2025-05-24 07:28:25 +02:00
Jérémie Bolduc
c64791e3a8 Stardew Valley: Replace current naive entrance rando with GER (#4624) 2025-05-24 07:15:41 +02:00
Aaron Wagener
e82d50a3c5 The Messenger: more generous portal validation (#5011)
* The Messenger: more generous portal validation

* remove the while and just go for 20 attempts. hopefully that's enough
2025-05-24 00:13:34 +02:00
qwint
0a7aa9e3e2 Launcher: skip launcher gui when opening webhost list with no game handlers (#4888)
* calc relevant components before opening the launcher app so it can be skipped for text client only uri launches

* generically passthrough the url arg

* Apply suggestions from code review

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

* flip if not else

* Update Launcher.py

* pluralize

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-05-24 00:02:50 +02:00
NewSoupVi
13ca134d12 Core: Fix a playthrough crash when a world uses "placement based logic" (#3915)
* Fix playthrough

* oops

* oops 2

* I don't like this

* that should do it

* Update BaseClasses.py

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

* Update BaseClasses.py

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-05-23 23:47:21 +02:00
Jérémie Bolduc
8671e9a391 Stardew Valley: Make animal catalog logically year 2 (#5032) 2025-05-23 19:52:47 +00:00
BlastSlimey
a7de89f45c shapez: Add game to README and CODEOWNERS (#5034)
* Aktualisieren von README.md

* Aktualisieren von CODEOWNERS
2025-05-23 19:41:27 +00:00
black-sliver
e9f51e3302 Linux: avoid adding cwd to LD_LIBRARY_PATH (#5029)
When LD_LIBRARY_PATH is not set, the old code would also add
the current working directory to LD_LIBRARY_PATH, which is bad.
2025-05-23 19:26:37 +00:00
Aaron Wagener
5491f8c459 Core: Make get_all_state Sweeping Optional (#4828) 2025-05-22 22:28:56 -04:00
Fabian Dill
de71677208 Core: only raise min_client_version for new gens (#4896) 2025-05-22 21:30:30 +02:00
Nicholas Saylor
653ee2b625 Docs: Update Snippets to Modern Type Hints (#4987) 2025-05-22 15:00:30 -04:00
qwint
62694b1ce7 Launcher: Fix on File Drop Error Message (#5026) 2025-05-22 11:37:23 -04:00
Rosalie
9c0ad2b825 FF1: Bizhawk Client and APWorld Support (#4448)
Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 11:35:38 -04:00
qwint
88b529593f CommonClient: Add docs for Attributes (#5003)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 11:08:15 -04:00
agilbert1412
0351698ef7 SDV: Fixed Import bases (#5025) 2025-05-22 11:07:57 -04:00
Jérémie Bolduc
984df75f83 Stardew Valley: Move and Rework Monstersanity Tests (#4911) 2025-05-22 10:24:04 -04:00
Mysteryem
402a8fb967 AHiT: Add Dweller Mask Requirement to Normal Logic Rush Hour (#4499) 2025-05-22 10:16:16 -04:00
Aaron Wagener
45e3027f81 The Messenger: Add a Component Icon and Description (#4850)
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 10:06:44 -04:00
Aaron Wagener
1d655a07cd Core: Add State add/remove/set Helpers (#4845) 2025-05-22 09:46:33 -04:00
FlitPix
c5e768ffe3 Minecraft: Stop Using Utils.get_options (#4879) 2025-05-22 09:42:54 -04:00
Aaron Wagener
8cc6f10634 The Messenger: Swap Options Docstrings to use rst, Add Option Groups (#4913)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 09:40:50 -04:00
Aaron Wagener
aeac83d643 Generate: Don't Force Player Name for Weights Files (#4943) 2025-05-22 09:29:24 -04:00
qwint
95efcf6803 Tests: Create CollectionState after MultiWorld.worlds (#4949) 2025-05-22 09:27:18 -04:00
josephwhite
44a78cc821 OoT: Stop Using Utils.get_options (#4957) 2025-05-22 09:26:28 -04:00
Scipio Wright
e0918a7a89 TUNIC: Move some UT stuff out of init, put in UT poptracker integration support (#4967) 2025-05-22 09:24:50 -04:00
qwint
b52310f641 Wargroove: Cleanup script_name Component in LauncherComponents (#5021) 2025-05-22 09:12:28 -04:00
Silvris
e3219ba452 WebHost: allow APPlayerContainers from "custom" worlds to be displayed in rooms (#4981)
Gives WebHost the ability to verify that a patch file is an APPlayerContainer (defined by #4331 as a APContainer containing the "player" field), and allowed it to display any patch file that it can verify is an APPlayerContainer.
2025-05-22 09:47:48 +02:00
Fly Hyping
7079c17a0f Wargroove: apworld doc fixes (#5023) 2025-05-22 09:11:34 +02:00
black-sliver
3b8450036a core: don't reconfigure stdout if it's fake (#5020) 2025-05-22 01:22:55 +02:00
Fly Hyping
defdf34e60 Wargroove: apworld (#4764)
- Players and AI can sacrifice their own units and upload them to the multiworld.
- Players and AI can summon random units from the multiworld.
- Has 4 new separate options for how many sacrifices and summons either the player or the AI can make per level attempt.
- New /sacrifice_summon command to toggle sacrifices and summons on/off. Useful if the AI makes a level impossible with their summons.
- Linux Support.
- Is an apworld now.


---------

Co-authored-by: Raspberry Floof <raspberry@rosenthalcastle.org>
Co-authored-by: KScl <ks@rosenthalcastle.org>
Co-authored-by: Abigail Fox <Raspberryfloof@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-05-22 01:00:45 +02:00
Fabian Dill
6827368e60 Core: generate templates faster and "cleaner" (#5019) 2025-05-22 00:45:49 +02:00
Katelyn Gigante
a409167f64 core: Reconfigure stdout to utf8 (#5017) 2025-05-21 20:27:03 +02:00
Natalie Weizenbaum
a076b9257d DS3: Don't make unrandomized items into events (#5018)
The DS3 static randomizer uses the relative ordering of location names
to map between Archipelago's notion of location IDs and the static
randomizer's. Treating unrandomized locations as excluded can break this
behavior by removing some locations from the list, causing further
locations to be incorrectly assigned.

The only reason this wasn't a bigger problem up to this point was that
location order only matters on a per-region and per-item basis. That
means this only causes problems in practice when a single region has
multiple locations with the same default item, and some of those
locations are randomized while others are not. Since exclusions (and
thus randomization) are usually done based on item types, we managed to
dodge this bullet for a long time.
2025-05-21 18:59:04 +02:00
Sunny Bat
7e772b4ee9 Raft: Small Raft doc update, bugfix (#5008)
* Small doc touchups

* Advanced Scarecrow progressive

* Add period to doc

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

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-05-21 18:12:37 +02:00
Alchav
955a86803f Super Mario Land 2: Implement New Game (#2730)
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: alchav <alchav@jalchavware.com>
2025-05-21 11:02:30 -04:00
BlastSlimey
d5bacaba63 shapez: Implement New Game (#3960)
Adds shapez as a supported game in AP.
2025-05-21 14:30:39 +02:00
massimilianodelliubaldini
3069deb019 Jak and Daxter: Implement New Game (#3291)
* Jak 1: Initial commit: Cell Locations, Items, and Regions modeled.

* Jak 1: Wrote Regions, Rules, init. Untested.

* Jak 1: Fixed mistakes, need better understanding of Entrances.

* Jak 1: Fixed bugs, refactored Regions, added missing Special Checks. First spoiler log generated.

* Jak 1: Add Scout Fly Locations, code and style cleanup.

* Jak 1: Add Scout Flies to Regions.

* Jak 1: Add version info.

* Jak 1: Reduced code smell.

* Jak 1: Fixed UT bugs, added Free The Sages as Locations.

* Jak 1: Refactor ID scheme to better fit game's scheme. Add more subregions and rules, but still missing one-way Entrances.

* Jak 1: Add some one-ways, adjust scout fly offset.

* Jak 1: Found Scout Fly ID's for first 4 maps.

* Jak 1: Add more scout fly ID's, refactor game/AP ID translation for easier reading and code reuse.

* Jak 1: Fixed a few things. Four maps to go.

* Jak 1: Last of the scout flies mapped!

* Jak 1: simplify citadel sages logic.

* Jak 1: WebWorld setup, some documentation.

* Jak 1: Initial checkin of Client. Removed the colon from the game name.

* Jak 1: Refactored client into components, working on async communication between the client and the game.

* Jak 1: In tandem with new ArchipelaGOAL memory structure, define read_memory.

* Jak 1: There's magic in the air...

* Jak 1: Fixed bug translating scout fly ID's.

* Jak 1: Make the REPL a little more verbose, easier to debug.

* Jak 1: Did you know Snowy Mountain had such specific unlock requirements? I didn't.

* Jak 1: Update Documentation.

* Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.

* Jak 1: Simplified startup process, updated docs, prayed.

* Jak 1: quick fix to settings.

* Jak and Daxter: Implement New Game (#1)

* Jak 1: Initial commit: Cell Locations, Items, and Regions modeled.

* Jak 1: Wrote Regions, Rules, init. Untested.

* Jak 1: Fixed mistakes, need better understanding of Entrances.

* Jak 1: Fixed bugs, refactored Regions, added missing Special Checks. First spoiler log generated.

* Jak 1: Add Scout Fly Locations, code and style cleanup.

* Jak 1: Add Scout Flies to Regions.

* Jak 1: Add version info.

* Jak 1: Reduced code smell.

* Jak 1: Fixed UT bugs, added Free The Sages as Locations.

* Jak 1: Refactor ID scheme to better fit game's scheme. Add more subregions and rules, but still missing one-way Entrances.

* Jak 1: Add some one-ways, adjust scout fly offset.

* Jak 1: Found Scout Fly ID's for first 4 maps.

* Jak 1: Add more scout fly ID's, refactor game/AP ID translation for easier reading and code reuse.

* Jak 1: Fixed a few things. Four maps to go.

* Jak 1: Last of the scout flies mapped!

* Jak 1: simplify citadel sages logic.

* Jak 1: WebWorld setup, some documentation.

* Jak 1: Initial checkin of Client. Removed the colon from the game name.

* Jak 1: Refactored client into components, working on async communication between the client and the game.

* Jak 1: In tandem with new ArchipelaGOAL memory structure, define read_memory.

* Jak 1: There's magic in the air...

* Jak 1: Fixed bug translating scout fly ID's.

* Jak 1: Make the REPL a little more verbose, easier to debug.

* Jak 1: Did you know Snowy Mountain had such specific unlock requirements? I didn't.

* Jak 1: Update Documentation.

* Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.

* Jak 1: Simplified startup process, updated docs, prayed.

* Jak 1: quick fix to settings.

* Jak and Daxter: Genericize Items, Update Scout Fly logic, Add Victory Condition. (#3)

* Jak 1: Update to 0.4.6. Decouple locations from items, support filler items.

* Jak 1: Total revamp of Items. This is where everything broke.

* Jak 1: Decouple 7 scout fly checks from normal checks, update regions/rules for orb counts/traders.

* Jak 1: correct regions/rules, account for sequential oracle/miner locations.

* Jak 1: make nicer strings.

* Jak 1: Add logic for finished game. First full run complete!

* Jak 1: update group names.

* Jak and Daxter - Gondola, Pontoons, Rules, Regions, and Client Update

* Jak 1: Overhaul of regions, rules, and special locations. Updated game info page.

* Jak 1: Preparations for Alpha. Reintroducing automatic startup in client. Updating docs, readme, codeowners.

* Alpha Updates (#15)

* Jak 1: Consolidate client into apworld, create launcher icon, improve setup docs.

* Jak 1: Update setup guide.

* Jak 1: Load title screen, save states of in/outboxes.

* Logging Update (#16)

* Jak 1: Separate info and debug logs.

* Jak 1: Update world info to refer to Archipelago Options menu.

* Deathlink (#18)

* Jak 1: Implement Deathlink. TODO: make it optional...

* Jak 1: Issue a proper send-event for deathlink deaths.

* Jak 1: Added cause of death to deathlink, fixed typo.

* Jak 1: Make Deathlink toggleable.

* Jak 1: Added player name to death text, added zoomer/flut/fishing text, simplified GOAL call for deathlink.

* Jak 1: Fix death text in client logger.

* Move Randomizer (#26)

* Finally remove debug-segment text, update Python imports to relative paths.

* HUGE refactor to Regions/Rules to support move rando, first hub area coded.

* More refactoring.

* Another refactor - may squash.

* Fix some Rules, reuse some code by returning key regions from build_regions.

* More regions added. A couple of TODOs.

* Fixed trade logic, added LPC regions.

* Added Spider, Snowy, Boggy. Fixed Misty's orbs.

* Fix circular import, assert orb counts per level, fix a few naming errors.

* Citadel added, missing locs and connections fixed. First move rando seed generated.

* Add Move Rando to Options class.

* Fixed rules for prerequisite moves.

* Implement client functionality for move rando, add blurbs to game info page.

* Fix wrong address for cache checks.

* Fix byte alignment of offsets, refactor read_memory for better code reuse.

* Refactor memory offsets and add some unit tests.

* Make green eco the filler item, also define a maximum ID. Fix Boggy tether locations.

* Move rando fixes (#29)

* Fix virtual regions in Snowy. Fix some GMC problems.

* Fix Deathlink on sunken slides.

* Removed unncessary code causing build failure.

* Orbsanity (#32)

* My big dumb shortcut: a 2000 item array.

* A better idea: bundle orbs as a numerical option and make array variable size.

* Have Item/Region generation respect the chosen Orbsanity bundle size. Fix trade logic.

* Separate Global/Local Orbsanity options. TODO - re-introduce orb factory for per-level option.

* Per-level Orbsanity implemented w/ orb bundle factory.

* Implement Orbsanity for client, fix some things up for regions.

* Fix location name/id mappings.

* Fix client orb collection on connection.

* Fix minor Deathlink bug, add Update instructions.

* Finishing Touches (#36)

* Set up connector level thresholds, completion goal choices.

* Send AP sender/recipient info to game via client.

* Slight refactors.

* Refactor option checking, add DataStorage handling of traded orbs.

* Update instructions to change order of load/connect.

* Add Option check to ensure enough Locations exist for Cell Count thresholds. Fix Final Door region.

* Need some height move to get LPC sunken chamber cell.

* Rename completion_condition to jak_completion_condition (#41)

* The Afterparty (#42)

* Fixes to Jak client, rules, options, and more.

* Post-rebase fixes.

* Remove orbsanity reset code, optimize game text in client.

* More game text optimization.

* Added more specific troubleshooting/setup instructions.

* Add known issue about large releases taking time. (Dodge 6,666th commit.)

* Remove "Bundle of", Add location name groups, set better default RootDirectory for new players.

* Make orb trade amounts configurable, make orbsanity defaults more reasonable.

* Add HUD info to doc.

* Exempt's Code Review Updates (#43)

* Round 1 of code review updates, the easy stuff.

* Factor options checking away from region/rule creation.

* Code review updates round 2, more complex stuff.

* Code review updates round 3: the mental health annihilator

* Code review updates part 4: redemption.

* More code review feedback, simplifying code, etc.

* Added a host.yaml option to override friendly limits, plus a couple of code review updates.

* Added singleplayer limits, player names to enforcement rules.

* Updated friendly limits to be more strict, optimized recalculate logic.

* Today's the big day Jak: updates docs for mod support in OpenGOAL Launcher

* Rearranged and clarified some instructions, ADDED PATH-SPACE FIX TO CLIENT.

* Fix deathlink reset stalls on a busy client. (#47)

* Jak & Daxter Client : queue game text messages to get items faster during release (#48)

* queue game text messages to write them during the main_tick function and empty the message queue faster during release

* wrap comment for code style character limit

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

* remove useless blank line

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

* whitespace code style

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

* Move JsonMessageData dataclass outside of ReplClient class for code clarity

---------

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

* Item Classifications (and REPL fixes) (#49)

* Changes to item classifications

* Bugfixes to power cell thresholds.

* Fix bugs in item_type_helper.

* Refactor 100 cell door to pass unit tests.

* Quick fix to ReplClient.

* Not so quick fix to ReplClient.

* Display friendly limits in options tooltips.

* Use math.ceil like a normal person.

* Missed a space.

* Fix non-accessibility due to bad orb calculation.

* Updated documentation.

* More Options, More Docs, More Tests (#51)

* Reorder cell counts, require punch for Klaww.

* Friendlier friendly friendlies.

* Removed custom_worlds references from docs/setup guide, focused OpenGOAL Launcher language.

* Increased breadth of unit tests.

* Clean imports of unit tests.

* Create OptionGroups.

* Fix region rule bug with Punch for Klaww.

* Include Punch For Klaww in slot data.

* Update worlds/jakanddaxter/__init__.py

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

* Temper and Harden Text Client (#52)

* Provide config path so OpenGOAL can use mod-specific saves and settings.

* Add versioning to MemoryReader. Harden the client against user errors.

* Updated comments.

* Add Deathlink as a "statement of intent" to the YAML. Small updates to client.

* Revert deathlink changes.

* Update error message.

* Added color markup to log messages printed in text client.

* Separate loggers by agent, write markup to GUI and non-markup to disk simultaneously.

* Refactor MemoryReader callbacks from main_tick to constructor.

* Make callback names more... informative.

* Give users explicit instructions in error messages.

* Stellar Messaging (#54)

* Use new ap-messenger functions for text writing.

* Remove Powershell requirement, bump memory version to 3.

* Error message update w/ instructions for game crash.

* Create no console window for gk.

* ISO Data Enhancement (#58)

* Add iso-path as argument to GOAL compiler.

# Conflicts:
#	worlds/jakanddaxter/Client.py

* More resilient handling of iso_path.

* Fixed scout fly ID mismatches.

* Corrected iso_data subpath.

* Update memory version to 4.

* Docs update for iso_data.

* Auto Detect OpenGOAL Install (#63)

* Auto detect OpenGOAL install path. Also fix Deathlink on server connection.

* Updated docs, add instructions to error messages.

* Slight tweak to error text.

* J&D : add per region location groups (#64)

* add per region power cells location group

* add per region scout flies location group

* add per zone orb bundle groups
(I'm not particularly happy about this code, but I figured doing it this way was the point of least friction/duplication)

* guess who forgot 9 very important characters in each line of the last commit

* Rearrange location group names, quick fix to client error handling.

* Fix pycharm warnings.

* Fix more pycharm warnings.

* Light cleanup: fix icons, add bug report page, remove py 3.8 code.

* Update worlds/jakanddaxter/Options.py

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update worlds/jakanddaxter/Options.py

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update worlds/jakanddaxter/Options.py

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update worlds/jakanddaxter/Options.py

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Code review updates on comments, tooltips, and type hints.

* Update type hint for lists in regions.

* Missed todo removal.

* More type hint updates.

* Small region updates for location accessibility, small updates to world guide and README.md.

* Add GMC scout fly location group.

* Improved sanitization of game text.

* Traps 2 (#70)

* Add trap items, relevant options, and citadel orb caches.

* Update REPL to send traps to game.

* Fix item counter.

* Allow player to select which traps to use.

* Fix host.yaml doc strings, ap-setup-options typing, bump memory version to 5.

* Alter some trap names.

* Update world doc.

* Add health trap.

* Added 3 more trap types.

* Protect against empty trap list.

* Reword traps paragraph in world doc.

* Another update to trap paragraph.

* Concisify trap option docstring.

* Timestamp on game log file.

* Update client to handle waiting on title screen.

* Send slot name and seed to game.

* Use self.random instead.

* Update setup doc for new title screen.

* Quick clarification of orb caches in world doc.

* Sanitize slot info earlier.

* Added to and improved unit tests.

* Light cleanup on world.

* Optimizations to movement rules, docs: known issues update.

* Quick fixes for beta 0.5.0 release: template options and LPC logic.

* Quick fix to spoiler counts.

* Reorganize world guide for faster navigation.

* Fix links.

* Update HUD section.

* Found a way to render apostrophes in item names.

* March Refactors (#77)

* Reorg imports, small fix to Rock Village movement.

* Fix wait-on-title message never going to ready message.

* Colorama init fix.

* Swap trap list for a dictionary of trap weights.

* The more laws, the less justice.

* Quick readability update.

* Have memory reader provide instructions for slow booting games.

* Revert some things.

* Update setup_en.md

* Update HUD mode lingo for combined msgs.

* Remade launcher icon, sized correctly.

* I don't know why I can't be satisfied with things.

* Apply suggestions from Scipio

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

* Properly use the settings API instead of Utils.

* Newline on requirements.txt.

* Add __init__ files for frozen builds.

* Replace an ap_inform function with a CommonClient built-in.

* Resize icon to match kivymd expected size.

* First round of Treble code reviews.

* Second round of Treble code reviews.

* Third round of Treble code reviews.

* Missed an unncessary if condition.

* Missed unnecessary comments.

* Fourth round of Treble code reviews.

* Switch trap dictionary to OptionCounter.

* Use existing slot name/seed from network protocol.

* Violet code review updates.

* Violet code review updates part 2.

* Refactor to avoid floating imports (Violet part 3).

* Found a few more valid characters for messaging.

* Move tests out of init, add colon to game name (now that it's safe).

* But don't include those chars for file text.

* Implement Vi suggestion on webhost-capable friendly limits.

* Revert "Implement Vi suggestion on webhost-capable friendly limits."

This reverts commit 2d012b7f4a.

* Rename all files for PEP8.

* Refactor how maximums work on webhost.

* Fix rogue UT.

* Don't rush.

* Fix client post-PEP8.

---------

Co-authored-by: Justus Lind <DeamonHunter@users.noreply.github.com>
Co-authored-by: Romain BERNARD <30secondstodraw@gmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2025-05-21 14:12:27 +02:00
NewSoupVi
7f4bf71807 Adventure: Update AdventureDeltaPatch.read_contents to return the manifest as required by #4331 (#5016) 2025-05-21 14:12:00 +02:00
Doug Hoskisson
f3e00b6d62 Zillion: fix read_contents to be compatible with base class (#5015) 2025-05-21 01:48:24 +02:00
Fabian Dill
feef0f484d Core: disable worlds_disabled (#5014) 2025-05-21 00:52:00 +02:00
Fabian Dill
9adbd4031f Core: prepare worlds.Files for APWorldContainer (#4331)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-05-20 23:55:16 +02:00
Mysteryem
e0d3101066 Core: Remove redundant reachable location counting in swap (#4990)
`prev_state` starts off as a copy of `swap_state` and then `swap_state`
collects `item_to_place`. Collecting an item must never reduce
accessibility (otherwise generation breaks horribly), so it is
guaranteed that `swap_state` will always be able to reach at least as
many locations as `prev_state`, so `new_loc_count >= prev_loc_count` is
always `True`.

As a sideeffect of this change, this fixes generation of Pokemon Emerald
with locally shuffled Badges/HMs when there are worlds with unconnected
entrances present in the multiworld e.g. KH1. This is because this
location counting did not respect `single_player_placement=True` and
counted reachable locations across the entire multiworld.

Fixes #4834 as a sideeffect of removing the redundant code.
2025-05-20 21:23:44 +02:00
SunCat
485387ebbe ChecksFinder: Update setup guide (#4973)
* Update setup_en.md

* Update worlds/checksfinder/docs/setup_en.md

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

* Update worlds/checksfinder/docs/setup_en.md

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

* Update worlds/checksfinder/docs/setup_en.md

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-20 20:12:13 +02:00
Seldom
9ac628f020 Terraria: remove 1.4.3-specific docs #5013 2025-05-20 20:11:44 +02:00
PoryGone
07664c4d54 SA2B: Logic Fixes (#5009)
- Fixes Shadow's mission count being set by Sonic's mission count option
- Fixes one small logic error on `Security Hall - 5` on Hard Logic difficulty
- Removes stray character that was probably harmless
2025-05-20 00:48:31 +02:00
Aaron Wagener
d3dbdb4491 Kivy: Add a button prompt box (#3470)
* Kivy: Add a button prompt box

* auto format the buttons to display 2 per row to look nicer

* update to kivymd

* have the uri popup use the new API

* have messenger use the new API

* make the buttonprompt import even more lazy

* messenger needs to be lazy too

* make the buttons take up the full dialog width

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-05-19 01:08:39 +02:00
Jérémie Bolduc
90ee9ffe36 Stardew Valley: Remove Crab Pot Requirement for Help Wanted Fishing (#4985)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-17 09:20:53 -04:00
el-u
15e6383aad lufia2ac: rearrange tests to comply with new conventions (#5001) 2025-05-15 17:58:10 +00:00
Scipio Wright
2a0d0b4224 Noita: Modernization Refactor (#4980) 2025-05-14 07:55:45 -04:00
Nicholas Saylor
02fd75c018 Core: Update Some Outdated Typing (#4986) 2025-05-14 07:40:38 -04:00
agilbert1412
a87fec0cbd SDV: Add Missing Marriage Requirement for Spouse Stardrop (#4988) 2025-05-14 07:27:15 -04:00
Natalie Weizenbaum
11842d396a DS3: Fix the Name of "Red and White Round Shield" (#4994)
This item name is unusual in that it loses the word "round" when it's
infused, *and* the only guaranteed drop in the base game is the infused
"Blessed Red and White Round Shield +1". But since we're just listing
the uninfused version, we should use the uninfused name.
2025-05-14 07:23:12 -04:00
Ixrec
72854cde44 Docs: Add a "Missable Locations" Question to apworld FAQ (#4965)
* Docs: add a "missable locations" question to apworld_dev_faq.md

Basically turning the conversation at https://discord.com/channels/731205301247803413/1214608557077700720/1368996789260128388 into a FAQ entry.

* feedback

* qwint feedback

* Update docs/apworld_dev_faq.md

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-14 07:21:40 -04:00
Duck
b71c8005e7 AHiT: Fix Client Argument Handling (#4992) 2025-05-14 07:18:36 -04:00
Ixrec
0994afa25b Tests: actually run tests in __init__.py files (#4969)
* demonstrate our pytest/CI configuration missing a __init__ test failure

* tell pytest/CI to run tests in __init__.py files

* revert the demonstration test failure

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-13 09:59:41 +02:00
Jérémie Bolduc
7d5693e0fb Stardew Valley: Move BaseTest out of __init__.py to comply with future conventions (#4991)
* move everything out of init; fix from imports and some typing errors

* why is there a change in multiserver

* fix some relative shits
2025-05-13 09:58:03 +02:00
black-sliver
feaed7ea00 Docs: tests: add naming / file naming conventions (#4982)
* Docs: tests: add naming / file naming conventions

Deprecates putting stuff into `__init__.py`.
This may be relevant for test discovery in the future.

* Docs: tests: fix class naming

* Docs: tests: update examples

* Punctuation is hard

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

* Revert part of one suggestion

The first set of () make the sentence make less sense.

* Docs: tests: clarify that __init__.py may be empty

* Make sentence nicer to read

I simply kept the original wording, but I agree that it reads somewhat odd

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
2025-05-13 09:49:43 +02:00
Justus Lind
8340371f9c Muse Dash: Update to Otaku Pack Vol 20 (#4924)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-12 18:47:19 -04:00
Emerassi
824caaffd0 Docs: clarify that ModuleUpdate.py is a prerequisite for running tests (#4970)
* Update tests.md

Spelled out that tests will not run without running UpdateModule.py first and including a link to the instructions on how to do that.

* Applied black-silver's feedback and also I ran into tests that don't run correctly unless you also have run Webhost.py once.  I have included that in the documentation as well.

* More black-silver feedback.
2025-05-11 12:41:35 +02:00
lordlou
c0b3fa9ff7 SMZ3: replace copyright credits music (#4978) 2025-05-11 08:10:51 +02:00
Aaron Wagener
e809b9328b The Messenger: do all empty state validation during portal shuffle (#4971) 2025-05-11 00:57:16 +02:00
qwint
53defd3108 MultiServer: More Guardrails for Nolocation Clients (#4470) 2025-05-10 18:51:44 -04:00
Silvris
a166dc77bc Core: Plando Items "Rewrite" (#3046) 2025-05-10 18:49:49 -04:00
Szabó Benedek Zoltán
68ed208613 DS3: "US: Homeward Bone - foot, drop overlook" (#4875) 2025-05-10 18:31:05 -04:00
agilbert1412
8f71dac417 Stardew valley: Add Trap Distribution setting (#4601)
Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-10 17:57:24 -04:00
Katelyn Gigante
5f24da7e18 Core: Use the location of Utils.py rather than __main__ to determine the AP Folder (#4009) 2025-05-10 15:20:43 +02:00
NewSoupVi
4e61f1f23c Core: Institute limit of 10000 items on StartInventory (#4972)
* Institute limit on StartInventory

* Update Options.py

* Update Options.py

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

* Update Options.py

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-10 04:11:39 +02:00
Fabian Dill
cbfcaeba8b Subnautica: use less multiworld API (#4977) 2025-05-10 00:05:18 +02:00
palex00
9a8abeac28 Add blurb about patch files to the host page (#4974) 2025-05-09 14:27:43 +00:00
digiholic
b0f42466f0 MMBN3: Adds Beach Access to Help With Rehab Job Bonus Reward Check (#4963) 2025-05-08 13:31:00 -04:00
kbranch
bcd7d62d0b LADX: Improve Fake Tracker Items (#4897) 2025-05-07 14:53:58 -04:00
digiholic
703f5a22fd OSRS: New Tasks, New Options, Compatibility with new Plugin Features (#4688) 2025-05-07 13:43:03 -04:00
Benjamin S Wolf
1ee8e339af Launcher: Warn if there is no File Browser (#4275) 2025-05-07 12:51:26 -04:00
Ixrec
dffde64079 Docs: add a "soft logic" question to apworld_dev_faq.md (#4953)
* add a "soft logic" question to apworld_dev_faq.md

* Update apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

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

* Update docs/apworld_dev_faq.md

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

* add a reminder about progression and how it influences soft logic implementations

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-07 12:20:21 -04:00
Scipio Wright
17bc184e28 TUNIC: Add Hidden all_random Option (#4635) 2025-05-07 10:59:16 -04:00
qwint
0ba9ee0695 Docs: update line length in apworld faq doc (#4960) 2025-05-07 10:47:14 -04:00
Scipio Wright
c40214e20f Docs: Minor Changes to apworld_dev_faq.md (#4947)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-05-07 10:41:37 -04:00
Scipio Wright
a3aac3d737 TUNIC: Entrance rando Direction Pairs + Decoupled (#3761)
* Fix merge conflict

* Fix formatting, fix rule for heir access after merge

* Writing combat logic helpers

* More helpers!

* More logic!

* Rename has_stick to has_melee, some fixes per Medic's review

* Clamp max power from sword upgrades

* Wrote the rest of the helpers

* Remove unused import

* Apply item classifications

* Create the combat logic option

* Item classification varies based on option

* Add the shop sword logic stuff in

* Add the rules for the boss-only option

* Fix tiny issues

* Some early Overworld combat logic

* Fill out swamp combat logic

* Add note

* Bump up Boss Scav and Heir

* More revisions to combat logic

* Some changes, currently broken

* New system for power, kinda jank probably

* Revisions to new system, needs more balancing

* Cap attack upgrades

* Uncap mp power since it's directly related to damage output

* Voidlings

* Put together a table showing the vanilla-expected stats for each area

* Added some info on potion counts

* Made new helper functions

* Make has_required_stats

* Make has_combat_reqs

* Update er_rules for new combat reqs

* Fix all the broken things ever

* Remove outdated todo

* Make temp option for testing logic

* More flexible choices for combat items

* Hard require sword for bosses

* Temporarily default combat logic to on

* Finish writing overworld combat logic

* East Forest combat logic done

* Remove a few easy ones

* Finish beneath the well

* Dark Tomb combat logic

* West Garden combat logic

* make unit tests checkmark again

* Weird west garden dagger house edge case

* Try block for that weird west garden edge case

* Add quarry combat logic

* Update to filter out unreachable regions outside of ER

* Fortress Grave Path logic, and a couple fixes to the west garden logic

* Fortress east shortcut logic, and rewriting the try except blocks to use finally

* Refactor to use a new function cause wow there was a lot of repeated code

* Add combat logic to the other two sets of fortress fuses

* Add combat rules to beneath the vault

* Fix missing cathedral -> elevator connection

* Combat logic for cathedral to elevator

* Add cathedral main region, rename cathedral -> cathedral entry

* Setup cathedral combat logic

* Adjust locations' regions for ER

* Add laurels zip logic to the chest in the spike room in cathedral

* Add combat logic to frog's domain

* Move frog's domain locations to regions for combat logic

* Add new frog's domain regions for combat logic

* Update region name for frog's domain

* Fix typo

* Add more regions for lower zig

* Move around lower zig regions for combat logic

* Lower Zig combat logic

* Upper zig combat logic

* Fix typo

* Fix typos

* Fix missing world.

* Update combat logic description

* Add todo

* Add todo

* Don't make zig skip if er or fixed shop is off

* Make it so zig skip is only made with fewer shops and er

* Temporarily default combat logic on

* Update test to explicitly disable combat logic

* Update test_access.py

* Slight wording changes

* Fix bugs, refactor quarry regions so you can access chests in lower quarry with ice grapples

* Run through checks you can do with magic dagger

* Run through checks you can do with magic dagger

* Add rule for entering town portal of having equipment to deal with enemies

* Add rule for atoll near the 6 crabs surrounding a poor defenseless baby slorm

* Update the rule for the chest near the 6 crabs surrounding a slorm to also possibly require laurels

* Revamp combat logic function to work properly without melee

* Add laurels rules to combat logic chests

* Modify beneath the vault bridge rule to need a lantern if combat logic is on

* Put in money logic

* Dagger or combat for swamp big skeleton chest

* Remove the 100 moneys from logic

* Modify lower zig ls drop region destinations

* Remove completed todo

* Reword combat logic option description, remove test option

* Add combat logic to slot data

* Merge Silent's missing slot data bugfix PR #3628

* Remove test combat option

* Update combat logic description

* Fix secret gathering place issue

* Fix secret gathering place issue

* Fix lower zig ls rule

* Fix accidentally removed librarian rule

* Remove redundant rule

* Update gauntlet rule to hard-require a sword

* Add test for a problematic connection

* Adjust combat logic to deal with weird edge cases so it doesn't take stuff out of logic that was previously in logic

* Fix create_item classification

* Update some comments

* Update per exempt's suggestion

* Add combat logic to the well boss fight, reorder the combat logic stuff a little to better section them off

* Add EntranceLayout option

* Add back LogicRules as an invisible option, to not break old yamls

* Fix a bug with seed group, continue changing fixed shop to entrance layout

* Fix missed fixed shop -> entrance layout spot

* Fix bug in seed groups with fixed shop on and off

* Add entrance layout to the UT regen stuff

* Put direction. in, will add them later

* Remove unused elevation from portal class

* Got like half of them in

* Finish adding all of the directions

* Add combat rule for zig front to back

* Update per Medic's suggestion

* Update ladder storage without items option description

* Mess with state with collect and remove to save like 2 seconds (never again)

* Save even more time, still never going to do this again on anything else

* Add option check for collect and remove

* Add directions to shop portals

* Update direction in Portal with default

* Move Direction above Portal

* Add decoupled option, mess with plando connection stuff

* Merge, implement verify plando directions

* Condense the stuff in change and remove to less lines (thanks medic)

* Remove unused thing

* Swap to using logicmixin instead of prog_items (thanks Vi)

* Fix consistency in stat counters

* Add back something that was needed

* Fix mistake when adding back

* 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

* Fix library mistake

* 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

* # 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

* Swap to .get for decoupled so it works with older games probably maybe

* Fix after merge

* Fix typo

* Fix UT support with fixed shop option

* Backport plando connections fix

* Fix issue with fixed shop + decoupled

* Make the error not duplicate the while loop condition

* Fix rule for quarry back to monastery

* Fix more stuff after merge

* Make it not output anything if you set plando connections but not ER

* Add obvious note to plando connections description

* Fix after merge

* add comment to commented out connection

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-06 12:33:21 -04:00
Seldom
7bbe62019a Terraria: Fix inaccessible Leading Landlord achievement when getfixedboi is enabled #4958 2025-05-06 18:32:55 +02:00
Aaron Wagener
b898b9d9e6 The Messenger: fix indentation in setup guide (#4959)
* The Messenger: fix indentation in setup guide

* just delete the save backup section tbh
2025-05-06 18:32:30 +02:00
Exempt-Medic
b217372fea Core: Make Perfect Fuzzy Match Prioritize Casing (#4956) 2025-05-05 19:18:20 -04:00
Jérémie Bolduc
b2d2c8e596 Stardew Valley: Add void mayo requirement for Goblin Problem quest (#4933)
This adds the requirement of a void mayo for the Goblin Problem quest. There are also some small adjustments to related rules
- Fishing a void mayo is only considered an option during the Goblin Problem quest, as the odds of finding one after the quest drops drastically.
- Entrance to the witch hut now requires the goblin problem quest, not just a void mayo.
- Fishing rules are all moved to `fishing_logic.py`.
- `can_fish_at` no longer check that you have any of the fishing regions and the region you actually want to fish in.
- created `can_fish_anywhere` and `can_crab_pot_anywhere` to better illustrate when any fish satisfies the rule.
2025-05-04 16:28:38 +02:00
Fabian Dill
68e37b8f9a Factorio: client cleanup and prevent process bomb (#4882)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-04 16:22:48 +02:00
Fabian Dill
fa2d7797f4 Core: update certifi (#4954) 2025-05-04 15:59:41 +02:00
Jonathan Tan
1885dab066 TWW: Documentation Cleanup (#4942) 2025-05-03 20:06:16 -04:00
Tim Mahan
9425f5b772 Docs: Direct Mac users to Launcher.py (#4767) 2025-05-03 08:42:52 -04:00
Fabian Dill
83ed3c8b50 Core: always embed Archipelago (#4880) 2025-05-03 11:53:52 +02:00
qwint
f4690e296d CommonClient: remove Datapackage Version handling (#4487)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-03 01:31:40 +02:00
Fabian Dill
68c350b4c0 CommonClient: rip out old global name lookup (#4941) 2025-05-02 23:39:52 +02:00
Fabian Dill
da0207f5cb Factorio: implement custom filler items (#4945) 2025-05-02 23:39:14 +02:00
Aaron Wagener
2455f1158f Options: Cleanup CommonOptions.as_dict (#4921)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-02 12:39:58 -04:00
Fabian Dill
1031fc4923 Factorio: remove FactorioClient executable (#4928) 2025-05-02 15:59:27 +02:00
qwint
6beaacb905 Generate: Better yaml parsing error messaging (#4927)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-05-02 09:46:34 -04:00
Scipio Wright
c46ee7c420 TUNIC: Lock pre-placed filler to make the game play nicer with prog balancing (#4917) 2025-04-30 21:57:46 +02:00
Bryce Wilson
227f0bce3d Pokemon Red/Blue: Convert to Procedure Patch (#4801) 2025-04-30 16:31:33 +02:00
PoryGone
611e1c2b19 SMW: v2.1 Feature Update (#4652)
### Features:
- Trap Link
  - When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
    - Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players

Co-authored-by: TheLX5 <luisyuregi@gmail.com>
2025-04-30 16:24:10 +02:00
Mysteryem
5f974b7457 SM: Fix FakeROM instances sharing the same data dictionary (#4912)
FakeROM instances were being created with default arguments, which
included a mutable default argument data dictionary, so all FakeROM
instances would be writing to and reading the same dictionary, resulting
in broken patch data in multiworlds with more than one Super Metroid
world.
2025-04-30 04:57:35 +02:00
threeandthreee
3ef35105c8 LADX: Remove copyrighted assets (#4935) 2025-04-30 04:27:54 +02:00
Alchav
ec768a2e89 ALTTP: Swamp Palace West logic fix (#4936) 2025-04-29 16:53:31 +02:00
black-sliver
b580d3c25a CI: add optional windows release build and build attestation (#4940)
* CI: github attestation for manually started builds

* CI: include appimage zsync in build attestation

* CI: github attestation for Linux release builds

* CI: reorder steps in build.yml

* CI: add windows builds to release.yml

* CI: order jobs in release.yml

* CI: add missing permission to release.yml

* CI: enable windows build in release.yml

* CI: false is skip
2025-04-29 08:32:36 +02:00
Jérémie Bolduc
ce14f190fb Stardew Valley: Replace event creation stardew code with add_event (#4922)
* replace event creation stardew code with add_event

* delete unnecessary default args
2025-04-29 00:12:52 +02:00
Jonathan Tan
4e3da005d4 TWW: Fix generation failure with output file (#4932) 2025-04-27 09:43:24 +02:00
Exempt-Medic
0d9967e8d8 OC2: Account for Multiclass Items in Progression Balancing (#4929) 2025-04-26 13:28:07 -04:00
KonoTyran
2624a0a7ea Remove Slay the Spire (#4673)
* Remove Slay the Spire

* remove slay the spire
2025-04-25 20:54:53 +02:00
Nicholas Brochu
8755d5cbc0 Remove Game: Zork Grand Inquisitor (#4884)
* remove zork grand inquisitor

* add apworld to inno setup installdelete
2025-04-25 01:42:42 +02:00
Jérémie Bolduc
abb6d7fbdb Stardew Valley: Replace all add_rule by set_rule #4909 2025-04-24 23:36:25 +02:00
Star Rauchenberger
fc04192c99 Lingo: Use OptionCounter for trap_weights (#4920) 2025-04-24 23:14:42 +02:00
Fabian Dill
d4110d3b2a LttP: make progression health optional (#4918) 2025-04-24 23:10:58 +02:00
NewSoupVi
05c1751d29 Core: Add "OptionCounter", use it for generic "StartInventory" and Witness "TrapWeights" (#3756)
* CounterOption

* bring back the negative exception for ItemDict

* Backwards compatibility

* ruff on witness

* fix in calls

* move the contains

* comment

* comment

* Add option min and max values for CounterOption

* Use min 0 for TrapWeights

* This is safe now

* ruff

* This fits on one line again now

* OptionCounter

* Update Options.py

* Couple more typing things

* Update Options.py

* Make StartInventory work again, also make LocationCounter theoretically work

* Docs

* more forceful wording

* forced line break

* Fix unit test (that wasn't breaking?)

* Add trapweights to witness option presets to 'prove' that the unit test passes

* Make it so you can order stuff

* Update macros.html
2025-04-24 22:06:41 +02:00
NewSoupVi
6ad042b349 Core: Add Region.add_event (#2965)
* region.add_event function

* Make it return the location bc why not

* Actually item bc that seems more useful

* Update BaseClasses.py

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

* Update BaseClasses.py

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

* add all the requested features from code review

* oop

* roughly sort args in order of importance (imo)

* Fix typing

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-04-24 21:56:52 +02:00
NewSoupVi
e52d8b4dbd The Witness: Remove first-stage requirements of progressive items from the logic files (#4257)
* Remove extraneous symbol requirements

* Some missed Full Dots cases

* Bruh

* merge error

* merge error 2
2025-04-24 21:56:05 +02:00
NewSoupVi
f288e3469c Core: Add a function docstring to roll_settings to hopefully prevent the weights fiasco from being repeated (#3388)
* Add an option docstring to roll_settings to hopefully prevent the weights fiasco from being repeated

* Update Generate.py

* Update Generate.py
2025-04-24 21:55:48 +02:00
Jarno
5bb87c6da5 Tests: Make overlapping test actually print out the overlaps (#4431) 2025-04-24 15:33:30 -04:00
Aaron Wagener
03768a5f90 Tests: Test that a world can generate with item links (#2081)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-24 15:23:51 -04:00
Scipio Wright
a84366368f Docs: Update comment for create_item (#4919) 2025-04-24 09:38:30 -04:00
Fabian Dill
29e6a10e42 Setup: offer the default-on option to clean /lib folder on update (#4890)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-04-24 08:50:34 +02:00
Fabian Dill
febd280fba Setup: use sha256 for timestamp server (#4892) 2025-04-23 20:30:15 +02:00
black-sliver
73964b374c MultiServer: import get_settings from the correct module (#4914)
* MultiServer: import get_settings from the correct module

* MultiServer: settings: use attr inbstead of dict access
2025-04-23 15:40:36 +00:00
Jérémie Bolduc
bad6a4b211 Stardew Valley: remove BaseLogic generic so importing mixins is no longer needed (#4916)
* remove BaseLogic generic so importing mixins is no longer needed

* self review
2025-04-23 17:31:08 +02:00
Scipio Wright
57d3c52df9 TUNIC: More varied reserved locations for local_fill option (#4653)
* Make reserved locations more varied

* Use CollectionState(self.multiworld) instead of whatever it used to be
2025-04-21 23:41:20 +02:00
Star Rauchenberger
d309de2557 Lingo: Rework Early Good Items (#4910) 2025-04-21 16:06:24 -04:00
Scipio Wright
d5d56ede8b TUNIC: Remove Outdated Plando Code (#4908) 2025-04-21 15:20:22 -04:00
Fabian Dill
6613c29652 Core: print both world source paths in case of conflict (#4751) 2025-04-21 00:53:40 +02:00
NewSoupVi
1a6de25ab6 Core, all worlds: Hard-deprecate old options API (by August 10th 2024) (#3284)
* Core: deprecate old options API

* also deprecate assigning options via option_definitions

---------

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
2025-04-21 00:43:31 +02:00
NewSoupVi
b62c1364a9 MultiServer.py: Another Hint Priority + Item Links bug oh boy (#4874)
Basically, hints for itemlink worlds' locations get stored in ctx.hints under
1. the location's player
2. **every individual player** that is participating in the itemlink.

Right now, the updatehint code tries to replace and resend the hint under the itemlinked player, which doesn't work.
2025-04-21 00:43:05 +02:00
Fabian Dill
b59162737d LttP: increase gen rate of pedestal goal with limited rupee pool (#4905)
* LttP: increase gen rate of pedestal goal with limited rupee pool

* improve chance further if retro bow is involved
2025-04-20 23:04:40 +02:00
Jérémie Bolduc
543dcb27d8 Stardew Valley: Exclude maximum one resource packs from pool when in start inventory (#4839)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 10:51:03 -04:00
Jérémie Bolduc
22941168cd Stardew Valley: Refactor Animals to use Content Packs (#4320) 2025-04-20 10:17:22 -04:00
Scipio Wright
33dc845de8 TUNIC: Fix UT Issue with Fewer Shops Option (#4873) 2025-04-20 09:48:09 -04:00
LiquidCat64
be0f23beb3 CV64: Some DeathLink Adjustments (#4727) 2025-04-20 09:46:57 -04:00
Silvris
b76f2163a4 MM2: Fix invalid weakness failsafe and refactor weakness tests (#4899) 2025-04-20 09:08:30 -04:00
Omnises Nihilis
04aa471526 KH2: Update Docs (#4871) 2025-04-20 08:43:52 -04:00
Trevor L
b756a67c2a BRC: Update Setup Guide (#4861)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 08:31:58 -04:00
Jérémie Bolduc
a76ee010eb Stardew Valley: Make Bus and Boat Require Money (#4833) 2025-04-20 08:21:02 -04:00
shananas
eb1fef1f92 KH2: Update Docs (#4869) 2025-04-20 08:20:23 -04:00
Doug Hoskisson
e498cc7d48 Tests: Don't use type as Callable (#4866) 2025-04-20 07:21:40 -04:00
Doug Hoskisson
a26abe079e Zillion: Some Code Cleaning (#4780) 2025-04-20 07:07:17 -04:00
qwint
199b6bdabb Launcher: Update header docstring (#4777) 2025-04-20 07:04:56 -04:00
SunCat
e4bc7bd1cd Checksfinder: Fix the last remnant of outdated game description (#4893)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 00:16:46 -04:00
Silvris
20651df307 kvui: fix kwargs on ResizableTextField and ImageButton (#4903) 2025-04-20 01:21:11 +02:00
massimilianodelliubaldini
f857933748 Launcher: Add search box (#4863)
* Add fuzzy search box to Launcher.

* move func bind to the kv and prefer substring matching (#79)

* move the func bind to the kv

* prefer substr matching

* Remove fuzzy results, rely on substring only.

* Use early return instead of else.

* Add type hint to filter_clients_by_type.

* Activate search on keyboard input.

* Clear search box when filtering by type.

* Update Launcher.py

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

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-04-19 23:27:03 +02:00
Jérémie Bolduc
efe2b7c539 Core: Support default value with cache_self1 (#4667)
* add cache_self1_default and tests

* merge the two decorators

* just change the defaults of the wrap lol

* add test for default and default
2025-04-19 17:55:02 +02:00
Fabian Dill
e090153d93 LttP: fix generation if other games are involved (#4901) 2025-04-19 15:44:55 +02:00
Silvris
5088b02bfe Unittests: fix world unittests with unittest module (#4895) 2025-04-19 15:42:20 +02:00
Nicholas Saylor
57a716b57a LTTP: Update to options API (#4134)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-18 23:41:38 +02:00
Aaron Wagener
1b51714f3b LTTP: Rip Lttp specific entrance code out of core and use Region helpers (#1960) 2025-04-18 23:34:34 +02:00
ScootyPuffJr1
cb3d35faf9 LttP: Add keydrop locations to location groups (#4465) 2025-04-18 20:50:51 +02:00
Fabian Dill
a0c83b4854 Core: no longer log ID ranges on generate (#4013)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-18 20:49:08 +02:00
Fabian Dill
1b3ee0e94f Core: require clients to support overlapping IDs (#4451) 2025-04-18 20:41:09 +02:00
Mysteryem
552a6e7f1c Stardew Valley: Precollect building items in deterministic order (#4883)
#4239 refactored buildings, but introduced iteration of a set when precollecting the building items into start inventory.

The iteration order of sets varies between separate Python processes due to set order being partially based on the hashes of the objects in the set and because Python processes each have a random hash seed by default.
2025-04-18 18:41:46 +02:00
qwint
38bfb1087b Webhost: fix get_seeds api endpoint (#4889) 2025-04-18 18:15:59 +02:00
qwint
2dc55873f0 Webhost: add link to new session page (#4857)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-04-18 04:57:41 +02:00
qwint
4b1898bfaf HK: fix docs whitespace (#4885) 2025-04-18 00:57:17 +02:00
Silvris
125bf6f270 Core: Post-KivyMD cleanup 2 and enhancements (#4876)
* Adds a new class allowing TextFields to be resized
* Resizes most CommonClient components to be more in-line with pre-KivyMD
* Change the color of SelectableLabels and TooltipLabels to white
* Fixed ClientTabs not correctly showing the current tab indicator
* The server label now features a (i) icon to indicate that it can be hovered over.
* Changed the default `primary_palette` to `Lightsteelblue` and the default `dynamic_scheme_name` to `VIBRANT`
* Properly set attributes on `KivyJSONToTextParser.TextColors` so that proper typing can be utilized if an individual value is needed
* Fixed some buttons being discolored permanently once pressed
* Sped up the animations of button ripples and tab switching
* Added the ability to insert a new tab to `GameManager.add_client_tab`
* Hovering over the "Command" button in CommonClient will now display the contents of `/help` as a popup (note: this popup can be too large on default height for adequately large /help (SC2 Client), but should always fit fine on fullscreen).
* Fixed invalid sizing of MessageBox errors, and changed their text color to white
2025-04-16 00:09:27 +02:00
Seldom
1873c52aa6 Terraria: 1.4.4 and Calamity support (#3847)
* Terraria integration

* Precollected items for debugging

* Fix item classification

* Golem requires Plantera's Bulb

* Pumpkin Moon requires Dungeon

* Progressive Dungeon

* Reorg, Options.py work

* Items are boss flags

* Removed unused option

* Removed nothing

* Wall, Plantera, and Zenith goals

* Achievements and items

* Fixed The Cavalry and Completely Awesome achievements

* Made "Dead Men Tell No Tales" a grindy achievement

* Some docs, Python 3.8 compat

* docs

* Fix extra item and "Head in the Clouds" being included when achievements are disabled

* Requested changes

* Fix potential thread unsafety, replace Nothing with 50 Silver

* Remove a log

* Corrected heading

* Added incompatible mods list

* In-progress calamity integration

* Terraria events progress

* Rules use events

* Removed an intentional crash I accidentally left in

* Fixed infinite loop

* Moved rules to data file

* Moved item rewards to data file

* Generating from data file

* Fixed broken Mech Boss goal

* Changes Calamity makes to vanilla rules, Calamity final bosses goal

* Added Deerclops, fixed Zenith goal

* Final detailed vanilla pass

* Disable calamity goals

* Typo

* Fixed some reward items not adding to item pool

* In-progress unit test fixes

* Unit test fixes

* `.apworld` compat

* Organized rewards file, made Frog Leg and Fllpper available in vanilla

* Water Walking Boots and Titan Glove rewards

* Add goals to slot data

* Fixed Hammush logic in Post-Mech goal

* Fixed coin rewards

* Updated Terraria docs

* Formatted

* Deathlink in-progress

* Boots of the Hero is grindy

* Fixed zenith goal not placing an item

* Address review

* Gelatin World Tour is grindy

* Difficulty notice

* Switched some achievements' grindiness

* Added "Hey! Listen!" achievement

* Terarria Python 3.8 compat

* Fixed Terraria You and What Army logic

* Calamity minion accessories

* Typo

* Calamity integration

* `deathlink` -> `death_link`

Co-authored-by: Zach Parks <zach@alliware.com>

* Missing `.`

Co-authored-by: Zach Parks <zach@alliware.com>

* Incorrect type annotation

Co-authored-by: Zach Parks <zach@alliware.com>

* `deathlink` -> `death_link` 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Address review

* Fix bad merge

* Terraria utility mod recommendations

* Calamity minion armor logic

* ArmorMinions -> Armor Minions, boss rush goal, fixed unplaced item

* Fixed unplaced item

* Started on Terraria 1.4.4

* Crate logic

* getfixedboi, 1.4.4 achievements, shimmer, town slimes, `Rule`, `Condition`, etc

* More clam getfixedboi logic, bar decraft logic, `NotGetfixedboi` -> `Not Getfixedboi`

* Calamity fixes

* Calamity crate ore logic

* Fixed item accessibility not generating in getfixedboi, fixed not generating with incompatible options, fixed grindy function

* Early achievements, separate achievement category options

* Infinity +1 Sword achievement can be location in later goals

* The Frequent Flyer is impossible in Calamity getfixedboi

* Add Enchanted Sword and Starfury for starting inventories

* Don't Dread on Me is redundant in Calamity

* In Calamity getfixedboi, Queen Bee summons enemies who drop Plague Cell Canisters

* Can't use Gelatin Crystal outside Hallow

* You can't get the Terminus without flags

* Typo

* Options difficult warnings

* Robbing the Grave is Hardmode

* Don't reserve an ID for unused Victory item

* Plantera is accessible early in Calamity via Giant Plantera's Bulbs

* Unshuffled Life Crystal and Defender Medal items

* Comment about Midas' Blessing

* Update worlds/terraria/Options.py

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

* Remove stray expression

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

* Review suggestions

* Option naming caps consistency, add Laser Drill, Lunatic Cultist alt reqs, fix Eldritch Soul Artifact, Ceaseless Void reqs Dungeon

* Cal Clone doesn't drop Broken Hero Sword anymore, Laser Drill is weaker in Calamity

Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>

* Fix Acid Rain logic

* Fix XB-∞ Hekate failing accessibility checks (by commenting it out bc it doesn't affect logic)

* Hardmode ores being fishable early in Calamity is not a bug anymore

* Mecha Mayhem is inaccessible in getfixedboi

* Update worlds/terraria/Rules.dsv

Co-authored-by: Seafo <92278897+Seatori@users.noreply.github.com>

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>
2025-04-15 15:51:05 +02:00
1005 changed files with 218314 additions and 45777 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

1
.github/labeler.yml vendored
View File

@@ -21,7 +21,6 @@
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'

View File

@@ -29,7 +29,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

@@ -9,24 +9,36 @@ 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.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs:
# build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand
build-win: # RCs and releases may still be built and signed by hand
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -65,6 +77,18 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds
shell: bash
run: |
@@ -81,7 +105,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
@@ -117,10 +141,13 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -142,6 +169,16 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate
@@ -162,7 +199,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:
- "*"
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,21 @@ 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.
APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs:
create-release:
@@ -26,11 +36,79 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
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
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -49,10 +127,13 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -74,6 +155,14 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

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:
@@ -69,7 +75,7 @@ jobs:
os:
- ubuntu-latest
python:
- {version: '3.12'} # current
- {version: '3.13'} # current
steps:
- uses: actions/checkout@v4

7
.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
@@ -184,12 +183,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 APWorld" 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="$ContentRoot$/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

@@ -1,3 +1,4 @@
import sys
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
@@ -5,4 +6,4 @@ ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch()
launch(*sys.argv[1:])

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,14 @@ import functools
import logging
import random
import secrets
import warnings
from argparse import Namespace
from collections import Counter, deque
from collections import Counter, deque, defaultdict
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -54,12 +56,21 @@ class HasNameAndPlayer(Protocol):
player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
@@ -83,6 +94,8 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str]
random: random.Random
@@ -141,17 +154,11 @@ class MultiWorld():
self.algorithm = 'balanced'
self.groups = {}
self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
@@ -160,18 +167,17 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('plando_item_blocks', [])
set_player_attr('game', "Archipelago")
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, ...]:
@@ -216,17 +222,8 @@ class MultiWorld():
self.seed_name = name if name else str(self.seed)
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
from worlds import AutoWorld
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
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)
@@ -264,6 +261,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():
@@ -287,6 +285,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"]
@@ -427,23 +427,39 @@ 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) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
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:
"""
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:
self.worlds[item.player].collect(ret, item)
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
if collect_pre_fill_items:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
if perform_sweep:
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
return ret
def get_items(self) -> List[Item]:
@@ -545,7 +561,9 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
def can_beat_game(self,
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -554,25 +572,10 @@ class MultiWorld():
state = CollectionState(self)
if self.has_beaten_game(state):
return True
prog_locations = {location for location in self.get_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
@@ -688,6 +691,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}")
@@ -723,6 +732,7 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -850,20 +860,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:
@@ -999,6 +1122,17 @@ class CollectionState():
return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item)
if changed:
@@ -1007,6 +1141,33 @@ class CollectionState():
self.blocked_connections[item.player] = set()
self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum):
ONE_WAY = 1
@@ -1022,9 +1183,6 @@ class Entrance:
connected_region: Optional[Region] = None
randomization_group: int
randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
@@ -1043,10 +1201,8 @@ class Entrance:
return False
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
def connect(self, region: Region) -> None:
self.connected_region = region
self.target = target
self.addresses = addresses
region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
@@ -1098,13 +1254,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)
@@ -1118,8 +1274,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:
@@ -1130,8 +1286,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:
@@ -1190,8 +1346,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.
@@ -1203,6 +1358,48 @@ class Region:
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_event(
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
) -> Item:
"""
Adds an event location/item pair to the region.
:param location_name: Name for the event location.
:param item_name: Name for the event item. If not provided, defaults to location_name.
:param rule: Callable to determine access for this event location within its region.
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
:return: The created Event Item
"""
if location_type is None:
location_type = Location
if item_name is None:
item_name = location_name
if item_type is None:
item_type = Item
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
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
event_location.place_locked_item(event_item)
self.locations.append(event_location)
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
@@ -1237,16 +1434,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, Callable[[CollectionState], bool]] | 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(
@@ -1336,31 +1533,47 @@ class Location:
class ItemClassification(IntFlag):
filler = 0b0000
filler = 0b00000
""" aka trash, as in filler items like ammo, currency etc """
progression = 0b0001
progression = 0b00001
""" Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """
useful = 0b0010
useful = 0b00010
""" Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b0100
trap = 0b00100
""" Item that is detrimental in some way. """
skip_balancing = 0b1000
skip_balancing = 0b01000
""" should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
Possible reasons for why an item should not be pulled ahead by progression balancing:
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
progression_skip_balancing = 0b1001 # only progression gets balanced
deprioritized = 0b10000
""" Should technically never occur on its own.
Will not be considered for priority locations,
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
Should be used for items that would feel bad for the player to find on a priority location.
Usually, these are items that are plentiful or insignificant. """
progression_deprioritized_skip_balancing = 0b11001
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
these items often want both flags. """
progression_skip_balancing = 0b01001 # only progression gets balanced
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
def as_flag(self) -> int:
"""As Network API flag int."""
return int(self & 0b0111)
return int(self & 0b00111)
class Item:
@@ -1404,6 +1617,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)
@@ -1513,21 +1730,19 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it
restore_later: Dict[Location, Item] = {}
required_locations = {location for sphere in collection_spheres for location in sphere}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
# we remove the location from required_locations to sweep from, and check if the game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if multiworld.can_beat_game(state_cache[num]):
required_locations.remove(location)
if multiworld.can_beat_game(state_cache[num], required_locations):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
location.item = old_item
required_locations.add(location)
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
@@ -1544,7 +1759,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
if not multiworld.can_beat_game(multiworld.state, required_locations):
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
@@ -1586,9 +1801,6 @@ class Spoiler:
self.create_paths(state, collection_spheres)
# repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
multiworld.push_precollected(item)
@@ -1645,6 +1857,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)
@@ -1653,6 +1868,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)
@@ -1689,7 +1907,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')
@@ -1716,7 +1935,7 @@ class Tutorial(NamedTuple):
description: str
language: str
file_name: str
link: str
link: str # unused
authors: List[str]

View File

@@ -21,7 +21,7 @@ import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from MultiServer import CommandProcessor, mark_raw
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start
@@ -107,7 +107,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 +130,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 +220,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"""
@@ -196,25 +243,12 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
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:
@@ -224,7 +258,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
@@ -254,7 +288,6 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -281,38 +314,71 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
slot_info: dict[int, NetworkSlot]
"""Slot Info from the server for the current connection"""
server_address: str | None
"""Autoconnect address provided by the ctx constructor"""
password: str | None
"""Password used for Connecting, expected by server_auth"""
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
"""Bool to keep track of state for the /ready command"""
team: int | None
"""Team number of currently connected slot"""
slot: int | None
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
locations_checked: set[int]
"""
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
to be used to ensure that a LocationChecks packet does not get lost when disconnected
"""
locations_scouted: set[int]
"""
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
# data storage
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
stored_data: dict[str, typing.Any]
"""
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
# message box reporting a loss of connection
"""Current message box through kvui"""
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state
@@ -356,11 +422,12 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
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
@@ -571,7 +638,6 @@ class CommonContext:
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -580,33 +646,26 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
if game not in remote_data_package_checksums:
continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
if not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
if remote_checksum != cached_checksum:
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
if remote_checksum == local_checksum:
self.update_game(network_data_package["games"][game], game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
if remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -616,7 +675,6 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
@@ -629,6 +687,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:
@@ -780,9 +856,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 ""
@@ -887,9 +963,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.server_auth(args['password'])
@@ -930,6 +1005,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",
@@ -1010,11 +1091,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" ]

View File

@@ -1,267 +0,0 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
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"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

484
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from Options import Accessibility
from worlds.AutoWorld import call_all
@@ -100,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -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,40 +141,50 @@ 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.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
# cleanup at the end to hopefully get better errors
cleanup_required = True
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
break
# Item can't be placed here, restore original item
location.item = placed_item
@@ -242,7 +263,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
total = min(len(itempool), len(locations))
placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -341,10 +362,17 @@ 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"}
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
@@ -365,7 +393,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -459,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
@@ -501,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
@@ -677,9 +743,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
logging.info("Skipping multiworld progression balancing.")
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
@@ -777,7 +843,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
@@ -793,8 +859,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
@@ -867,52 +933,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2
def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f'{warning}')
logging.debug(f"{warning}")
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
player_ids: set[int] = set(multiworld.player_ids)
for player in player_ids:
for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
plando_blocks[player] = []
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
if target_world is False or multiworld.players == 1: # target own world
worlds: typing.Set[int] = {player}
worlds: set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
@@ -921,173 +965,201 @@ def distribute_planned(multiworld: MultiWorld) -> None:
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.",
block['force'])
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])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block['force'])
block.force)
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
block.force)
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
new_block.worlds = worlds
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
items: list[str] | dict[str, typing.Any] = block.items
if isinstance(items, dict):
item_list: typing.List[str] = []
item_list: list[str] = []
for key, value in items.items():
if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
items = [items]
block['items'] = items
new_block.items = items
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
locations: list[str] = block.locations
if isinstance(locations, str):
locations = [locations]
if isinstance(locations, dict):
location_list = []
for key, value in locations.items():
location_list += [key] * value
locations = location_list
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:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
count = block.count
if not count:
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"] = (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)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds:
locations += early_locations[target_player]
resolved_locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds:
locations += non_early_locations[target_player]
resolved_locations += non_early_locations[target_player]
block['locations'] = list(dict.fromkeys(locations))
if block.count["max"] > len(block.items):
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
if not block.count["target"]:
removed.append(block)
if block['count']['target'] > 0:
plando_blocks.append(block)
for block in removed:
multiworld.plando_item_blocks[player].remove(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
if len(block.resolved_locations) > 0
else len(multiworld.get_unfilled_locations(block.player)) -
block.count["target"]))
for placement in plando_blocks:
player = placement['player']
player = placement.player
try:
worlds = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
from_pool = placement['from_pool']
worlds = placement.worlds
locations = placement.resolved_locations
items = placement.items
maxcount = placement.count["target"]
from_pool = placement.from_pool
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
item_candidates = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
allowed_margin = len(item_candidates) - mincount
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
allow_partial=True, name="Plando Main Fill")
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
from typing import Any
import ModuleUpdate
@@ -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):
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")
@@ -77,7 +77,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> Tuple[argparse.Namespace, int]:
def main(args=None) -> tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
@@ -95,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: Dict[str, Tuple[Any, ...]] = {}
weights_cache: dict[str, tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -166,21 +166,12 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
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
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 = {}
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
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()}
@@ -205,30 +196,34 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
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 = {}
args.player_options = {}
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
try:
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
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
getattr(args, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
# name was not specified
if player not in args.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
args.name[player] = f"Player{player}"
else:
# use the filename
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1
except Exception as e:
@@ -236,13 +231,13 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
else:
raise RuntimeError(f'No weights specified for player {player}')
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())}")
if len(set(name.lower() for name in args.name.values())) != len(args.name):
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
return erargs, seed
return args, seed
def read_weights_yamls(path) -> Tuple[Any, ...]:
def read_weights_yamls(path) -> tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -252,7 +247,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return tuple(parse_yamls(yaml))
from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
def interpret_on_off(value) -> bool:
@@ -321,12 +329,6 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
@@ -371,7 +373,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
from worlds import AutoWorldRegister
if not game:
@@ -392,7 +394,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if roll_percentage(option_set["percentage"]):
if Options.roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
@@ -425,7 +427,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
@@ -456,6 +458,14 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
"""
Roll options from specified weights, usually originating from a .yaml options file.
Important note:
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
This means it should never be modified without making a deepcopy first.
"""
from worlds import AutoWorldRegister
if "linked_options" in weights:
@@ -476,7 +486,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:
@@ -521,10 +546,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

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

@@ -1,24 +1,26 @@
"""
Archipelago Launcher
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
* If run with component name as argument, run it passing argv[2:] as arguments.
* If run without arguments or unknown arguments, open launcher GUI.
Scroll down to components= to add components to the launcher as well as setup.py
Additional components can be added to worlds.LauncherComponents.components.
"""
import argparse
import logging
import multiprocessing
import os
import shlex
import subprocess
import sys
import urllib.parse
import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union, Any
from typing import Any
if __name__ == "__main__":
import ModuleUpdate
@@ -40,13 +42,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 = []
@@ -84,12 +90,20 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, folder_path])
else:
webbrowser.open(folder_path)
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
@@ -99,66 +113,51 @@ 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."),
])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
def handle_uri(path: str) -> tuple[list[Component], Component]:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = []
client_components = []
text_client_component = None
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
game = queries["game"][0]
for component in components:
if component.supports_uri and component.game_name == game:
client_component.append(component)
client_components.append(component)
elif component.display_name == "Text Client":
text_client_component = component
from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
).open()
return client_components, text_client_component
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None:
return None, None
for component in components:
@@ -169,7 +168,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
return None, None
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
def get_exe(component: str | Component) -> Sequence[str] | None:
if isinstance(component, str):
name = component
component = None
@@ -197,7 +196,8 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
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')
@@ -222,18 +222,19 @@ def create_shortcut(button: Any, component: Component) -> None:
button.menu.dismiss()
refresh_components: Optional[Callable[[], None]] = None
refresh_components: Callable[[], None] | None = None
def run_gui(path: str, args: Any) -> None:
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
from kivy.metrics import dp
from kivymd.uix.button import MDIconButton
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.textfield import MDTextField
from kivy.lang.builder import Builder
@@ -253,15 +254,16 @@ def run_gui(path: str, args: Any) -> None:
navigation: MDGridLayout = ObjectProperty(None)
grid: MDGridLayout = ObjectProperty(None)
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, path=None, args=None):
def __init__(self, ctx=None, components=None, args=None):
self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx
self.icon = r"data/icon.png"
self.favorites = []
self.launch_uri = path
self.launch_components = components
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -338,14 +340,29 @@ def run_gui(path: str, args: Any) -> None:
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients(self, caller):
def filter_clients_by_type(self, caller: MDButton):
self._refresh_components(caller.type)
self.search_box.text = ""
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
sub_matches = [
card for card in self.cards
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
]
self.button_layout.layout.clear_widgets()
for card in sub_matches:
self.button_layout.layout.add_widget(card)
def build(self):
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
self.search_box = self.top_screen.ids.search_box
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
@@ -353,18 +370,24 @@ def run_gui(path: str, args: Any) -> None:
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):
if self.launch_uri:
handle_uri(self.launch_uri, self.launch_args)
self.launch_uri = None
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
@staticmethod
@@ -382,7 +405,16 @@ def run_gui(path: str, args: Any) -> None:
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {file}")
logging.warning(f"unable to identify component for {filename}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
# Limit text input to ASCII non-control characters (space bar to tilde).
if not self.search_box.focus:
self.search_box.focus = True
if key in range(32, 126):
self.search_box.text += codepoint
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
@@ -396,7 +428,7 @@ def run_gui(path: str, args: Any) -> None:
for filter in self.current_filter))
super().on_stop()
Launcher(path=path, args=args).run()
Launcher(components=launch_components, args=args).run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -415,7 +447,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
def main(args: argparse.Namespace | dict | None = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
@@ -423,7 +455,15 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if not path.startswith("archipelago://"):
if path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path)
if file:
args['file'] = file
@@ -439,12 +479,12 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui(path, args.get("args", ()))
run_gui(args.get("launch_components", None), args.get("args", ()))
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):

116
Main.py
View File

@@ -1,20 +1,21 @@
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
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
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, get_settings
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
@@ -22,7 +23,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
@@ -36,10 +37,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.plando_options = args.plando
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -56,29 +54,20 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
item_digits = len(str(max_item))
location_digits = len(str(max_location))
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())))
del max_item, max_location
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}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
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_digits, location_digits, item_count, location_count
del item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output and not args.spoiler_only:
@@ -110,6 +99,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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")
@@ -117,12 +115,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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:
@@ -143,11 +135,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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)
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic")
@@ -155,7 +147,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({})
depletion_pool: Dict[int, Dict[str, int]] = {
depletion_pool: dict[int, dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids
}
@@ -164,7 +156,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
}
if target_per_player:
new_itempool: List[Item] = []
new_itempool: list[Item] = []
# Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool:
@@ -189,12 +181,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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.")
distribute_planned(multiworld)
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.')
@@ -247,17 +240,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
er_hint_data: dict[int, dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
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]
@@ -272,7 +267,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
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()
@@ -288,7 +285,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
@@ -315,20 +312,21 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
}
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
spheres: list[dict[int, set[int]]] = []
for sphere in multiworld.get_sendable_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))
multidata = {
multidata: NetUtils.MultiData | bytes = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -338,7 +336,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"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,
@@ -346,9 +344,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"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])
multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format

View File

@@ -1,344 +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
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["minecraft_options"].get("java", "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 = Utils.get_options()
channel = args.channel or options["minecraft_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["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_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,22 @@ import multiprocessing
import warnings
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
# 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+ 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 sys.version_info < (3, 11, 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+ 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 +74,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,12 +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, 1, 6)
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:
@@ -124,8 +134,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
@@ -134,6 +167,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
@@ -141,6 +175,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):
@@ -178,6 +217,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.
@@ -207,8 +247,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 = {}
@@ -241,6 +281,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[
@@ -444,7 +485,7 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
@@ -457,8 +498,12 @@ class Context:
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.minimum_client_versions[player] = max(Version(*version), min_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -541,6 +586,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))
@@ -621,6 +667,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}
}
@@ -655,6 +702,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"]
@@ -747,7 +795,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]]
@@ -762,8 +810,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]:
@@ -1128,8 +1177,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():
@@ -1145,25 +1199,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]
@@ -1173,13 +1241,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 []
@@ -1293,7 +1364,8 @@ class CommandProcessor(metaclass=CommandMeta):
argname += "=" + parameter.default
argtext += argname
argtext += " "
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s
def _cmd_help(self):
@@ -1322,19 +1394,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:")
@@ -1476,6 +1535,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":
@@ -1603,7 +1679,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]}
@@ -1629,9 +1704,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]
@@ -1651,16 +1726,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)
@@ -1825,7 +1902,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
client.no_locations = bool(client.tags & _non_game_messages.keys())
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = {
@@ -1899,7 +1976,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_locations = bool(client.tags & _non_game_messages.keys())
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
@@ -1938,13 +2015,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"]
@@ -1982,14 +2110,21 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
for slot in concerning_slots:
ctx.replace_hint(client.team, slot, hint, new_hint)
ctx.save()
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot)
elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"])
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -2172,6 +2307,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."""
@@ -2293,9 +2441,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)
@@ -2329,17 +2477,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:
@@ -2367,6 +2512,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 [])
@@ -2416,8 +2566,10 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
from settings import get_settings
parser = argparse.ArgumentParser()
defaults = Utils.get_settings()["server_options"].as_dict()
defaults = get_settings().server_options.as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2452,6 +2604,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)
@@ -2517,7 +2676,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
@@ -2552,7 +2711,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

@@ -12,6 +12,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
@@ -276,11 +277,12 @@ 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
async def run_game(romfile):
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
auto_start = OOTWorld.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -295,7 +297,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom_file_name = OOTWorld.settings.rom_file
rom = Rom(rom_file_name)
sub_file = None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
import collections
import functools
import logging
import math
@@ -23,6 +24,12 @@ if typing.TYPE_CHECKING:
import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError):
pass
@@ -487,6 +494,30 @@ class Choice(NumericOption):
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
def __lt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} < {other}"
other = self.options[other]
return super(Choice, self).__lt__(other)
def __gt__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} > {other}"
other = self.options[other]
return super(Choice, self).__gt__(other)
def __le__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
other = self.options[other]
return super(Choice, self).__le__(other)
def __ge__(self, other: typing.Union[Choice, int, str]):
if isinstance(other, str):
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
other = self.options[other]
return super(Choice, self).__ge__(other)
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -858,23 +889,57 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
return self.value.__getitem__(item)
return self.value[item]
def __iter__(self) -> typing.Iterator[str]:
return self.value.__iter__()
return iter(self.value)
def __len__(self) -> int:
return self.value.__len__()
return len(self.value)
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
return item in self.value
class ItemDict(OptionDict):
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
range_errors = []
if self.max is not None:
range_errors += [
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
for key, value in self.value.items() if value > self.max
]
if self.min is not None:
range_errors += [
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
for key, value in self.value.items() if value < self.min
]
if range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))
class ItemDict(OptionCounter):
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
super(ItemDict, self).__init__(value)
@@ -984,7 +1049,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
if roll_percentage(text.get("percentage", 100)):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
@@ -1010,7 +1075,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
if roll_percentage(text.percentage):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1026,10 +1091,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
yield from self.value
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)
return self.value[index]
def __len__(self) -> int:
return self.value.__len__()
return len(self.value)
class ConnectionsMeta(AssembleOptions):
@@ -1053,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple):
entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
percentage: int = 100
@@ -1134,7 +1199,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
if roll_percentage(percentage):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
@@ -1152,7 +1217,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
if roll_percentage(connection.percentage):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1176,7 +1241,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
connection.exit) for connection in value])
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)
return self.value[index]
def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value
@@ -1257,42 +1322,48 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
:param option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
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 in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
if option_name not in type(self).type_hints:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
return option_results
@@ -1309,14 +1380,15 @@ 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
max = 10000
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.
"""
@@ -1374,6 +1446,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),
}
])
@@ -1401,8 +1474,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)
@@ -1428,6 +1503,133 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1450,6 +1652,7 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1468,7 +1671,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
@@ -1503,6 +1706,7 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os
from inspect import cleandoc
import yaml
from jinja2 import Template
@@ -1541,18 +1745,23 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
)
del file_data
res = template.render(
option_groups=option_groups,
__version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)

View File

@@ -7,23 +7,19 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
* 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
@@ -43,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
@@ -63,7 +58,6 @@ Currently, the following games are supported:
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
@@ -82,6 +76,12 @@ Currently, the following games are supported:
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
* Paint
* Celeste (Open World)
* Choo-Choo Charles
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()

180
Utils.py
View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import typing
import builtins
@@ -35,7 +36,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 +48,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.2"
__version__ = "0.6.4"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -114,6 +115,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
cache[arg] = res
return res
wrap.__defaults__ = function.__defaults__
return wrap
@@ -137,8 +140,11 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
if globals().get("__file__") and os.path.isfile(__file__):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
@@ -161,6 +167,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
@@ -172,7 +182,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()
@@ -221,7 +231,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
@@ -308,11 +323,13 @@ def get_options() -> Settings:
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))
@@ -399,13 +416,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
@@ -427,6 +457,10 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type:
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
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
@@ -444,7 +478,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")
@@ -455,6 +489,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.
@@ -532,6 +578,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -630,6 +678,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
@@ -650,8 +700,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
if picks[0][1] == 101:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
@@ -669,13 +721,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
@@ -694,25 +755,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_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:
@@ -746,21 +812,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:
@@ -787,9 +850,6 @@ 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 is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -800,10 +860,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
@@ -855,7 +915,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],
@@ -892,15 +952,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
@@ -908,8 +968,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())
):
@@ -928,17 +987,23 @@ 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
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()
_extend_freeze_support()
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
@@ -1074,3 +1139,40 @@ 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

View File

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

@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
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,

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

@@ -0,0 +1,241 @@
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
@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))})
return {
"groups": groups,
"datapackage": tracker_data._multidata["datapackage"],
"player_locations_total": player_locations_total,
}
# 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,8 +27,8 @@ 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.slots),
"players": get_players(seed),
})
return jsonify(response)

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
@@ -97,6 +100,7 @@ class WebHostContext(Context):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
del commands
time.sleep(5)
@db_session
@@ -129,7 +133,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 +150,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(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@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
@@ -281,8 +286,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 +312,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:
@@ -321,6 +331,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
del room
logger.exception(e)
raise
else:
@@ -332,11 +343,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
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):
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
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

@@ -7,17 +7,27 @@ 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
def get_world_theme(game_name: str):
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def get_visible_worlds() -> dict[str, type(World)]:
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return worlds
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
@@ -31,83 +41,101 @@ 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
}
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@app.route('/faq/<string:lang>/')
@cache.cached()
def faq(lang: str):
import markdown
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
document = f.read()
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
return render_template(
"markdown_document.html",
title="Frequently Asked Questions",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
html_from_markdown=document,
)
@app.route('/glossary/<string:lang>/')
@cache.cached()
def glossary(lang: str):
import markdown
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
document = f.read()
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
return render_template(
"markdown_document.html",
title="Glossary",
html_from_markdown=markdown.markdown(
document,
extensions=["toc", "mdx_breakless_lists"],
extension_configs={
"toc": {"anchorlink": True}
}
),
html_from_markdown=document,
)
@@ -188,7 +216,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 +227,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 +240,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

@@ -76,7 +76,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'
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
@@ -155,7 +155,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 +214,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)
@@ -222,11 +227,11 @@ def generate_yaml(game: str):
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
# Detect and build ItemDict options from their name pattern
# Detect and build OptionCounter options from their name pattern
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,13 @@
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
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

@@ -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,279 @@
#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(img.acquired)){
display: none;
}
.hidden-item:not(.acquired){
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,30 +26,18 @@
<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 == "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 == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 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 | supports_apdeltapatch %}
{% 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>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}

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

@@ -111,10 +111,19 @@
</div>
{% endmacro %}
{% macro ItemDict(option_name, option) %}
{% macro OptionCounter(option_name, option) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
@@ -125,6 +134,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">
@@ -137,6 +147,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" %}
@@ -160,6 +171,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" %}
@@ -183,6 +195,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

@@ -93,8 +93,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -133,8 +135,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}

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

@@ -29,7 +29,8 @@
<div id="user-content-wrapper" class="markdown">
<div id="user-content" class="grass-island">
<h1>User Content</h1>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
<h2>Your Rooms</h2>
{% if rooms %}

View File

@@ -113,9 +113,18 @@
{{ TextChoice(option_name, option) }}
{% endmacro %}
{% macro ItemDict(option_name, option, world) %}
{% macro OptionCounter(option_name, option, world) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
<div class="dict-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input
@@ -130,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">
@@ -149,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" %}
@@ -171,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" %}
@@ -193,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">

View File

@@ -83,8 +83,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}

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

View File

@@ -16,21 +16,41 @@
orange: "FF7700" # Used for command echo
# KivyMD theming parameters
theme_style: "Dark" # Light/Dark
primary_palette: "Green" # Many options
dynamic_scheme_name: "TONAL_SPOT"
primary_palette: "Lightsteelblue" # Many options
dynamic_scheme_name: "VIBRANT"
dynamic_scheme_contrast: 0.0
<MDLabel>:
color: self.theme_cls.primaryColor
<BaseButton>:
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
font_size: dp(20)
theme_font_size: "Custom"
font_size: "20dp"
markup: True
halign: "left"
<SelectableLabel>:
size_hint: 1, None
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerLowColor
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
Rectangle:
size: self.size
pos: self.pos
@@ -154,9 +174,12 @@
<ToolTip>:
size: self.texture_size
size_hint: None, None
theme_font_size: "Custom"
font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
theme_text_color: "Custom"
text_color: (1, 1, 1, 1)
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
@@ -175,11 +198,30 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput>
<AutocompleteHintInput>:
size_hint_y: None
height: dp(30)
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<ConnectBarTextInput>:
height: "30dp"
multiline: False
write_tab: False
role: "medium"
size_hint_y: None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<CommandPromptTextInput>:
size_hint_y: None
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<MessageBox>:
height: self.content.texture_size[1] + 80
<ScrollBox>:
layout: layout
bar_width: "12dp"

View File

@@ -5,12 +5,13 @@
size_hint: 1, None
height: "75dp"
context_button: context
focus_behavior: False
MDRelativeLayout:
ApAsyncImage:
source: main.image
size: (48, 48)
size_hint_y: None
size_hint: None, None
pos_hint: {"center_x": 0.1, "center_y": 0.5}
MDLabel:
@@ -37,6 +38,7 @@
pos_hint:{"center_x": 0.85, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
on_release: app.set_favorite(self)
MDIconButton:
@@ -46,6 +48,7 @@
pos_hint:{"center_x": 0.95, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
MDButton:
pos_hint:{"center_x": 0.9, "center_y": 0.25}
@@ -53,7 +56,7 @@
height: "25dp"
component: main.component
on_release: app.component_action(self)
detect_visible: False
MDButtonText:
text: "Open"
@@ -77,7 +80,7 @@ MDFloatLayout:
id: all
style: "text"
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "asterisk"
@@ -87,7 +90,7 @@ MDFloatLayout:
id: client
style: "text"
type: (Type.CLIENT, )
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "controller"
@@ -97,7 +100,7 @@ MDFloatLayout:
id: Tool
style: "text"
type: (Type.TOOL, )
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "desktop-classic"
@@ -107,7 +110,7 @@ MDFloatLayout:
id: adjuster
style: "text"
type: (Type.ADJUSTER, )
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "wrench"
@@ -117,7 +120,7 @@ MDFloatLayout:
id: misc
style: "text"
type: (Type.MISC, )
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "dots-horizontal-circle-outline"
@@ -128,7 +131,7 @@ MDFloatLayout:
id: favorites
style: "text"
type: ("favorites", )
on_release: app.filter_clients(self)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "star"
@@ -138,5 +141,21 @@ MDFloatLayout:
MDNavigationDrawerDivider:
ScrollBox:
id: button_layout
MDGridLayout:
id: main_layout
cols: 1
spacing: "10dp"
MDTextField:
id: search_box
mode: "outlined"
set_text: app.filter_clients_by_name
MDTextFieldLeadingIcon:
icon: "magnify"
MDTextFieldHintText:
text: "Search"
ScrollBox:
id: button_layout

View File

@@ -365,18 +365,14 @@ request_handlers = {
["PREFERRED_CORES"] = function (req)
local res = {}
local preferred_cores = client.getconfig().PreferredCores
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
res["value"]["SNES"] = preferred_cores.SNES
res["value"]["GB"] = preferred_cores.GB
res["value"]["GBC"] = preferred_cores.GBC
res["value"]["DGB"] = preferred_cores.DGB
res["value"]["SGB"] = preferred_cores.SGB
res["value"]["PCE"] = preferred_cores.PCE
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
while systems_enumerator:MoveNext() do
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
end
return res
end,

View File

@@ -1,462 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function receive()
l, e = ff1Socket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

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

@@ -33,6 +33,10 @@ description: {{ 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) %}
# You can define additional values between the minimum and maximum values.
@@ -46,15 +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 }}:
{%- if option.__doc__ %}
# {{ option.__doc__
# {{ cleandoc(option.__doc__)
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| replace('\n', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}

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

Binary file not shown.

Binary file not shown.

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