Compare commits

...

107 Commits

Author SHA1 Message Date
NewSoupVi
938d6c9bd4 APQuest: Improve the auto-generated .gitignore for data/sounds
I didn't quite think this through: In this specific case, you want the gitignore to also ignore itself, since it itself is an auto-generated file.
2025-11-25 14:53:22 +01:00
NewSoupVi
f9630fa13b Core: Add a bunch of validation to AutoPatchRegister (#5431)
* Add a bunch of validation to AutoPatchRegister

* slightly change it

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

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

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

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

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

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

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

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

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

* Update worlds/apquest/client/custom_views.py

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

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

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

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

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

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

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

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

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

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

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

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

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

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

* more words in options.py

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

* Less confusing wording about rules, hopefully?

* victory -> completion

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

* access rule performance

* Make all imports module-level in world.py

* formatting

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

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

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

* subclass instead of override

* I forgor to remove these

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

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

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

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

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

---------

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

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

* comments

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

The current flow of accessibility works like this:

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

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

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

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

* Reword

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

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

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

* Pycharm pls

* Violet pls

* Remove OptionsType

---------

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

* small grammar

* suggested fix

client is die (sadge)

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

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

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

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

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

* Update Generate.py

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

* change to an elif to avoid unnecessary nesting

---------

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

* Fix editorial issues in documentations

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

* added in non-common-client version clarification

* Update docs/network protocol.md

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

---------

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

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

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

* Multiple: optimize EN RA network commands screenshot

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

* WebHost, Worlds: fix retroarch images not showing

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

Guides now also properly render on Github.

* Factorio: optimize screenshots

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

* Factorio: change guide screenshots to use relative URL

* Test: markdown: fix tests on Windows

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

* Test: markdown: fix typo

I hope that's it now. *sigh*

* Landstalker: fix doc images not showing

Change to relative img urls.

* Landstalker: optimize doc PNGs

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

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

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

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

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

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

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

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

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

* Update test/general/test_world_manifest.py

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

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

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

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

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

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

* accidentally added line

* WebHost: fix some typing

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

* Test: Hosting: also test autogen

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

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

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

---------

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

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

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

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

* WebHost: fix shutting down autogen

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

* WebHost: properly propagate JOB_TIME

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

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

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

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

* oops that's wrong

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

* Update worlds/ff1/data/locations.json

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

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

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

---------

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

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

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

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

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

* SC2: fix incorrect evil logic preset option

---------

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

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

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

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

* x y z for world version

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

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

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

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

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

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

* Update CommonClient.py

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

* Revert "init"

This reverts commit bba6b7a306.

* put it back but clean

* pass args

* windows stuff

* delete old exe

this seems like it?

* use marin icon in launcher

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

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

* Store /option changes to countdown_mode in save file

* Wording changes in host.yaml

* Use .get

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

* clarity

* expand example

* clarify

* correct

* Correct

* elaborate on what version is

* Add where the apworlds are output

* authors & update versions

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md
2025-10-09 20:23:21 +02:00
268 changed files with 8489 additions and 1670 deletions

View File

@@ -9,21 +9,24 @@ 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
# 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: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
@@ -139,9 +142,9 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract

View File

@@ -11,7 +11,7 @@ on:
- "!.github/workflows/**"
- ".github/workflows/docker.yml"
branches:
- "*"
- "main"
tags:
- "v?[0-9]+.[0-9]+.[0-9]*"
workflow_dispatch:

View File

@@ -11,9 +11,10 @@ env:
ENEMIZER_VERSION: 7.1
# 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: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
permissions: # permissions required for attestation
@@ -127,9 +128,9 @@ jobs:
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract

View File

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

View File

@@ -1721,9 +1721,10 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
if not multiworld.has_beaten_game(state):
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
"Something went terribly wrong here. "
f"Unreachable progression items: {sphere_candidates}")
else:
self.unreachables = sphere_candidates
break

8
CommonClient.py Normal file → Executable file
View File

@@ -572,6 +572,10 @@ class CommonContext:
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def is_connection_change(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out connection changes."""
return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict):
logger.info(args["text"])
@@ -856,9 +860,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 ""

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse():
def mystery_argparse(argv: list[str] | None = None):
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")
@@ -189,6 +189,11 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
elif key == "triggers":
if "triggers" not in yaml[category_name]:
yaml[category_name][key] = []
for trigger in option:
yaml[category_name][key].append(trigger)
else:
yaml[category_name][key] = option
@@ -362,7 +367,10 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
@@ -385,6 +393,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if option_key == "triggers":
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")

View File

@@ -75,12 +75,17 @@ def open_patch():
launch([*exe, file], component.cli)
def generate_yamls():
def generate_yamls(*args):
from Options import generate_yaml_templates
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
parser.add_argument("--skip_open_folder", action="store_true")
args = parser.parse_args(args)
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
open_folder(target)
if not args.skip_open_folder:
open_folder(target)
def browse_files():

View File

@@ -326,7 +326,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
if current_sphere:
spheres.append(dict(current_sphere))
multidata: NetUtils.MultiData | bytes = {
multidata: NetUtils.MultiData = {
"slot_data": slot_data,
"slot_info": slot_info,
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
@@ -350,11 +350,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(restricted_dumps(multidata), 9)
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format
f.write(multidata)
f.write(serialized_multidata)
output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():

View File

@@ -135,6 +135,7 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
__slots__ = (
"__weakref__",
"version",
"auth",
"team",
@@ -216,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.
@@ -245,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 = {}
@@ -279,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[
@@ -664,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}
}
@@ -698,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"]
@@ -1195,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
if found:
status = HintStatus.HINT_FOUND
elif status is None:
hint_status = HintStatus.HINT_FOUND
elif hint_status is None:
if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID
hint_status = HintStatus.HINT_AVOID
else:
status = HintStatus.HINT_PRIORITY
hint_status = HintStatus.HINT_PRIORITY
hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
)
return hints
@@ -1529,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":
@@ -2489,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 [])
@@ -2576,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)
@@ -2641,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

View File

@@ -688,6 +688,12 @@ class Range(NumericOption):
range_start = 0
range_end = 1
_RANDOM_OPTS = [
"random", "random-low", "random-middle", "random-high",
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
]
def __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
@@ -713,9 +719,26 @@ class Range(NumericOption):
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
else: # "false"
return cls(0)
return cls(int(text))
# "false"
return cls(0)
try:
num = int(text)
except ValueError:
# text is not a number
# Handle conditionally acceptable values here rather than in the f-string
default = ""
truefalse = ""
if hasattr(cls, "default"):
default = ", default"
if cls.range_start == 0 and cls.default != 0:
truefalse = ", \"true\", \"false\""
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
f"<int>{default}, high, low{truefalse}, "
f"{', '.join(cls._RANDOM_OPTS)}.")
return cls(num)
@classmethod
def weighted_range(cls, text) -> Range:
@@ -731,9 +754,7 @@ class Range(NumericOption):
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
@classmethod
def custom_range(cls, text) -> Range:
@@ -1018,6 +1039,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
supports_weighting = False
display_name = "Plando Texts"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@@ -1144,6 +1167,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""
@@ -1435,6 +1460,7 @@ class DeathLink(Toggle):
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
rich_text_doc = True
default = []
schema = Schema([
@@ -1474,8 +1500,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)
@@ -1724,11 +1752,16 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
for sub_option in ["random", "random-low", "random-high",
f"random-range-{option.range_start}-{option.range_end}"]:
if sub_option != option.default:
data[sub_option] = 0
notes = {}
notes = {
"random-low": "random value weighted towards lower values",
"random-high": "random value weighted towards higher values",
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
f"{option.range_start} and {option.range_end}"
}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:

661
OptionsCreator.py Normal file
View File

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

View File

@@ -82,6 +82,7 @@ Currently, the following games are supported:
* Paint
* Celeste (Open World)
* Choo-Choo Charles
* APQuest
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,7 +18,7 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils
from settings import Settings
import settings
from Utils import async_start
from MultiServer import mark_raw
if typing.TYPE_CHECKING:
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
sni_path = 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)
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None:
auto_start = Settings.sni_options.snes_rom_start
auto_start = settings.get_settings().sni_options.snes_rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import typing
import builtins
@@ -47,7 +48,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.4"
__version__ = "0.6.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -313,12 +314,8 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove when removing get_options
def get_options() -> Settings:
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
return get_settings()
@@ -477,7 +474,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")
@@ -754,6 +751,11 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
@@ -804,6 +806,51 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
initialfile=suggest or None)
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file save dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
# fall back to tk
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
f'This attempt was made because save_filename was used for "{title}".')
raise e
else:
if is_macos and is_kivy_running():
# on macOS, mixing kivy and tk does not work, so spawn a new process
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
from multiprocessing import Process, Queue
res: "Queue[typing.Optional[str]]" = Queue()
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
return res.get()
try:
root = tkinter.Tk()
except tkinter.TclError:
return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
@@ -1138,3 +1185,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

@@ -1,6 +1,7 @@
import base64
import os
import socket
import typing
import uuid
from flask import Flask
@@ -61,20 +62,21 @@ cache = Cache()
Compress(app)
def to_python(value):
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(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):
def to_python(self, value: str) -> uuid.UUID:
return to_python(value)
def to_url(self, value):
def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
return to_url(value)
@@ -84,7 +86,7 @@ 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

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,7 +172,7 @@ 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.")

View File

@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain
from Utils import __version__, restricted_dumps
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app
from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options
@@ -33,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"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)),
}
@@ -106,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
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)
@@ -117,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
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 = {}
@@ -136,7 +137,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse()
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
@@ -171,11 +172,12 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
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:
@@ -188,6 +190,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
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:
@@ -199,6 +204,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
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>')

90
WebHostLib/markdown.py Normal file
View File

@@ -0,0 +1,90 @@
import re
from collections import Counter
import mistune
from werkzeug.utils import secure_filename
__all__ = [
"ImgUrlRewriteInlineParser",
'render_markdown',
]
class ImgUrlRewriteInlineParser(mistune.InlineParser):
relative_url_base: str
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
super().__init__(hard_wrap)
self.relative_url_base = relative_url_base
@staticmethod
def _find_game_name_by_folder_name(name: str) -> str | None:
from worlds.AutoWorld import AutoWorldRegister
for world_name, world_type in AutoWorldRegister.world_types.items():
if world_type.__module__ == f"worlds.{name}":
return world_name
return None
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
res = super().parse_link(m, state)
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
image_token = state.tokens[-1]
url: str = image_token["attrs"]["url"]
if not url.startswith("/") and not "://" in url:
# replace relative URL to another world's doc folder with the webhost folder layout
if url.startswith("../../") and "/docs/" in self.relative_url_base:
parts = url.split("/", 4)
if parts[2] != ".." and parts[3] == "docs":
game_name = self._find_game_name_by_folder_name(parts[2])
if game_name is not None:
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
# change relative URL to point to deployment folder
url = f"{self.relative_url_base}/{url}"
image_token['attrs']['url'] = url
return res
def render_markdown(path: str, img_url_base: str | None = None) -> str:
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
# there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
if img_url_base:
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
html = markdown(document)
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
return html

View File

@@ -1,5 +1,7 @@
import datetime
import os
import warnings
from enum import StrEnum
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions
@@ -9,14 +11,29 @@ from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted
class WebWorldTheme(StrEnum):
DIRT = "dirt"
GRASS = "grass"
GRASS_FLOWERS = "grassFlowers"
ICE = "ice"
JUNGLE = "jungle"
OCEAN = "ocean"
PARTY_TIME = "partyTime"
STONE = "stone"
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
if game_name not in AutoWorldRegister.world_types:
return "grass"
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
available_themes = [theme.value for theme in WebWorldTheme]
if chosen_theme not in available_themes:
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
return "grass"
return chosen_theme
def get_visible_worlds() -> dict[str, type(World)]:
@@ -27,49 +44,6 @@ def get_visible_worlds() -> dict[str, type(World)]:
return worlds
def render_markdown(path: str) -> str:
import mistune
from collections import Counter
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
import re # there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
return markdown(document)
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
@@ -91,10 +65,9 @@ def game_info(game, lang):
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
lang = secure_filename(lang)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, f"{lang}_{secure_game_name}.md"
))
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",
@@ -119,10 +92,9 @@ def tutorial(game: str, file: str):
theme = get_world_theme(game)
secure_game_name = secure_filename(game)
file = secure_filename(file)
document = render_markdown(os.path.join(
app.static_folder, "generated", "docs",
secure_game_name, file+".md"
))
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",

View File

@@ -13,6 +13,7 @@ from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .generate import get_meta
from .misc import get_world_theme
def create() -> None:
@@ -22,12 +23,6 @@ def create() -> None:
Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
@@ -76,7 +71,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
return publish_parts(text, writer='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'

View File

@@ -4,9 +4,10 @@ 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
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1228,7 +1228,7 @@ if "Starcraft 2" in network_data_package["games"]:
def render_Starcraft2_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
SC2WOL_ITEM_ID_OFFSET = 1000
SC2HOTS_ITEM_ID_OFFSET = 2000
SC2LOTV_ITEM_ID_OFFSET = 2000
SC2LOTV_ITEM_ID_OFFSET = 3000
SC2_KEY_ITEM_ID_OFFSET = 4000
NCO_LOCATION_ID_LOW = 20004500
NCO_LOCATION_ID_HIGH = NCO_LOCATION_ID_LOW + 1000

2
ci-requirements.txt Normal file
View File

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

View File

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

174
data/optionscreator.kv Normal file
View File

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

View File

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

View File

@@ -15,6 +15,10 @@
# A Link to the Past
/worlds/alttp/ @Berserker66
# APQuest
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99

View File

@@ -1,40 +1,83 @@
# apworld Specification
# APWorld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
These are called "APWorlds".
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details.
APWorlds can either be a folder, or they can be packaged as an .apworld file.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
## .apworld File Format
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
by placing a `*.apworld` file into the worlds folder.
## File Format
apworld files are zip archives, all lower case, with the file ending `.apworld`.
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
**Warning:** `.apworld` files have to be all lower case,
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
## Metadata
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
The current format version has at minimum:
Metadata about the APWorld is defined in an `archipelago.json` file.
If the APWorld is a folder, the only required field is "game":
```json
{
"version": 6,
"compatible_version": 5,
"game": "Game Name"
}
```
There are also the following optional fields:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded.
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An APWorld without a world_version is always treated as older than one with a version
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
package managers. Should always be a list of strings.
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this:
```json
{
"game": "Game Name",
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"]
}
```
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
```json
{
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"],
"version": 7,
"compatible_version": 7,
"game": "Game Name"
}
```
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An apworld without a world_version is always treated as older than one with a version
This is the recommended workflow for packaging your world to an `.apworld`.
## Extra Data
@@ -43,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above.
## Caveats
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World`

View File

@@ -647,6 +647,16 @@ class Version(NamedTuple):
build: int
```
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
```
"version": {
"class": "Version",
"build": X,
"major": Y,
"minor": Z
}
```
### SlotType
An enum representing the nature of a slot.

View File

@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";

22
kvui.py
View File

@@ -34,6 +34,17 @@ from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
from kivy.core.audio import SoundLoader
for classobj in SoundLoader._classes:
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
classobj.extensions()
from kivymd.uix.divider import MDDivider
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
@@ -116,7 +127,7 @@ class ImageButton(MDIconButton):
val = kwargs.pop(kwarg, "None")
if val != "None":
image_args[kwarg.replace("image_", "")] = val
super().__init__()
super().__init__(**kwargs)
self.image = ApAsyncImage(**image_args)
def set_center(button, center):
@@ -132,6 +143,7 @@ class ImageButton(MDIconButton):
class ScrollBox(MDScrollView):
layout: MDBoxLayout = ObjectProperty(None)
box_height: int = NumericProperty(dp(100))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -142,6 +154,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
def __init__(self, *args, **kwargs):
super(ToggleButton, self).__init__(*args, **kwargs)
self.bind(state=self._update_bg)
self._update_bg(self, self.state)
def _update_bg(self, _, state: str):
if self.disabled:
@@ -159,7 +172,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
child.text_color = self.theme_cls.onPrimaryColor
child.icon_color = self.theme_cls.onPrimaryColor
else:
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
self.md_bg_color = self.theme_cls.surfaceContainerLowColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
@@ -173,7 +186,6 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
class ResizableTextField(MDTextField):
"""
Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule.
"""
def __init__(self, *args, **kwargs):
@@ -237,7 +249,7 @@ Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(MDTooltipPlain):
pass
markup = True
class ServerToolTip(ToolTip):
@@ -272,6 +284,8 @@ class TooltipLabel(HovererableLabel, MDTooltip):
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
if self.disabled:
return
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:

16
ruff.toml Normal file
View File

@@ -0,0 +1,16 @@
line-length = 120
indent-width = 4
target-version = "py311"
[lint]
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
ignore = [
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
"C901", # Author disagrees with limiting branch complexity
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
"PLC0415", # In AP, we consider local imports totally fine & necessary
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
"PLC1901", # This is just not equivalent
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
]

View File

@@ -579,6 +579,17 @@ class ServerOptions(Group):
"goal" -> Client can ask for remaining items after goal completion
"""
class CountdownMode(str):
"""
Countdown modes
Determines whether or not a player can initiate a countdown with !countdown
Note that /countdown is always available to the host.
"enabled" -> Client can always initiate a countdown with !countdown.
"disabled" -> Client can never initiate a countdown with !countdown.
"auto" -> !countdown will be available for any room with less than 30 slots.
"""
class AutoShutdown(int):
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
@@ -613,6 +624,7 @@ class ServerOptions(Group):
release_mode: ReleaseMode = ReleaseMode("auto")
collect_mode: CollectMode = CollectMode("auto")
remaining_mode: RemainingMode = RemainingMode("goal")
countdown_mode: CountdownMode = CountdownMode("auto")
auto_shutdown: AutoShutdown = AutoShutdown(0)
compatibility: Compatibility = Compatibility(2)
log_network: LogNetwork = LogNetwork(0)

View File

@@ -146,7 +146,16 @@ def download_SNI() -> None:
signtool: str | None = None
try:
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
import socket
sign_host, sign_port = "192.168.206.4", 12345
# check if the sign_host is on a local network
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((sign_host, sign_port))
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
raise ConnectionError() # would go through default route
# configure signtool
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
html = response.read()
if b"status=OK\n" in html:
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
@@ -381,7 +390,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"):
manifest = json.load(open(world_directory / "archipelago.json"))
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

@@ -1,4 +1,12 @@
def run_locations_benchmark():
def run_locations_benchmark(freeze_gc: bool = True) -> None:
"""
Run a benchmark of location access rule performance against an empty_state and an all_state.
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
than running all iterations for the location rule being benchmarked.
"""
import argparse
import logging
import gc
@@ -34,6 +42,8 @@ def run_locations_benchmark():
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
if freeze_gc:
gc.freeze()
with TimeIt(f"{test_location.game} {self.rule_iterations} "
f"runs of {test_location}.access_rule({state_name})", logger) as t:
for _ in range(self.rule_iterations):
@@ -41,6 +51,8 @@ def run_locations_benchmark():
# if time is taken to disentangle complex ref chains,
# this time should be attributed to the rule.
gc.collect()
if freeze_gc:
gc.unfreeze()
return t.dif
def main(self):
@@ -64,9 +76,13 @@ def run_locations_benchmark():
gc.collect()
for step in self.gen_steps:
if freeze_gc:
gc.freeze()
with TimeIt(f"{game} step {step}", logger):
call_all(multiworld, step)
gc.collect()
if freeze_gc:
gc.unfreeze()
locations = sorted(multiworld.get_unfilled_locations())
if not locations:

View File

@@ -1,7 +1,7 @@
import unittest
from BaseClasses import PlandoOptions
from Options import ItemLinks, Choice
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0])
def test_pickle_dumps(self):
"""Test options can be pickled into database for WebHost generation"""
def test_pickle_dumps_default(self):
"""Test that default option values can be pickled into database for WebHost generation"""
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
@@ -81,3 +81,23 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
class TestPlandoConnections(PlandoConnections):
entrances = {"An Entrance"}
exits = {"An Exit"}
plando_connection_value = PlandoConnections(
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
)
plando_values = {
"PlandoConnections": plando_connection_value,
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
}
for option_key, value in plando_values.items():
with self.subTest(option=option_key):
restricted_dumps(value)

View File

@@ -0,0 +1,102 @@
"""Check world sources' manifest files"""
import json
import unittest
from pathlib import Path
from typing import Any, ClassVar
import test
from Utils import home_path, local_path
from worlds.AutoWorld import AutoWorldRegister
from ..param import classvar_matrix
test_path = Path(test.__file__).parent
worlds_paths = [
Path(local_path("worlds")),
Path(local_path("custom_worlds")),
Path(home_path("worlds")),
Path(home_path("custom_worlds")),
]
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
source_world_names = [
k
for k, v in AutoWorldRegister.world_types.items()
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
]
def get_source_world_manifest_path(game: str) -> Path | None:
"""Get path of archipelago.json in the world's root folder from game name."""
# TODO: add a feature to AutoWorld that makes this less annoying
world_type = AutoWorldRegister.world_types[game]
world_type_path = Path(world_type.__file__)
for worlds_path in worlds_paths:
if world_type_path.is_relative_to(worlds_path):
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
manifest_path = world_root / "archipelago.json"
return manifest_path if manifest_path.exists() else None
assert False, f"{world_type_path} not found in any worlds path"
# TODO: remove the filter once manifests are mandatory.
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
class TestWorldManifest(unittest.TestCase):
game: ClassVar[str]
manifest: ClassVar[dict[str, Any]]
@classmethod
def setUpClass(cls) -> None:
world_type = AutoWorldRegister.world_types[cls.game]
assert world_type.game == cls.game
manifest_path = get_source_world_manifest_path(cls.game)
assert manifest_path # make mypy happy
with manifest_path.open("r", encoding="utf-8") as f:
cls.manifest = json.load(f)
def test_game(self) -> None:
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
self.assertIn(
"game",
self.manifest,
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
)
self.assertEqual(
self.manifest["game"],
self.game,
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
)
def test_world_version(self) -> None:
"""Test that world_version matches the requirements in apworld specification.md"""
if "world_version" in self.manifest:
world_version: str = self.manifest["world_version"]
self.assertIsInstance(
world_version,
str,
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
)
parts = world_version.split(".")
self.assertEqual(
len(parts),
3,
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
)
for part in parts:
self.assertTrue(
part.isdigit(),
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
)
def test_no_container_version(self) -> None:
self.assertNotIn(
"version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
)
self.assertNotIn(
"compatible_version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
)

View File

@@ -3,6 +3,7 @@
# Run with `python test/hosting` instead,
import logging
import traceback
from pathlib import Path
from tempfile import TemporaryDirectory
from time import sleep
from typing import Any
@@ -11,7 +12,7 @@ from test.hosting.client import Client
from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata)
stop_autogen, stop_autohost, upload_multidata, generate_remote)
from test.hosting.world import copy as copy_world, delete as delete_world
failure = False
@@ -56,35 +57,62 @@ else:
if __name__ == "__main__":
import sys
import warnings
warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", DeprecationWarning)
spacer = '=' * 80
with TemporaryDirectory() as tempdir:
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
p1_games: list[str] = []
data_paths: list[Path | None] = []
rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}")
print(f"Generating [{n}] {', '.join(games)} offline")
multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata)
p1_games.append(games[0])
finally:
delete_world("Temp World")
webapp = get_app(tempdir)
webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1):
assert multidata
seed = upload_multidata(webhost_client, multidata)
print(f"Uploaded [{n}] {multidata} as {seed}\n")
room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n")
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room)
# Generate 1 extra game on WebHost
from WebHostLib.autolauncher import autogen
for n, games in enumerate(multis[:1], len(multis) + 1):
multis.append(games)
try:
print(f"Generating [{n}] {', '.join(games)} online")
autogen(webapp.config)
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
seed = generate_remote(webhost_client, games)
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
finally:
stop_autogen()
data_paths.append(None) # WebHost-only
room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room)
print("Starting autohost")
@@ -96,31 +124,10 @@ if __name__ == "__main__":
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
prev_host_adr = host.address
with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages
@@ -134,6 +141,7 @@ if __name__ == "__main__":
autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below
sleep(2) # work around issue #5571
# verify server shut down
try:
@@ -156,6 +164,31 @@ if __name__ == "__main__":
"customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
if not multidata:
continue # games rolled on WebHost can not be tested against MultiServer
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datapackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
# compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer")
@@ -176,10 +209,12 @@ if __name__ == "__main__":
print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver")
print("Save file is not busted 🥳")
sleep(2) # work around issue #5571
finally:
print("Stopping autohost")

View File

@@ -1,6 +1,10 @@
import io
import json
import re
import time
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast
from typing import TYPE_CHECKING, Iterable, Optional, cast
from WebHostLib import to_python
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
__all__ = [
"get_app",
"generate_remote",
"upload_multidata",
"create_room",
"start_room",
@@ -17,6 +22,7 @@ __all__ = [
"set_room_timeout",
"get_multidata_for_room",
"set_multidata_for_room",
"stop_autogen",
"stop_autohost",
]
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
"TESTING": True,
"HOST_ADDRESS": "localhost",
"HOSTERS": 1,
"GENERATORS": 1,
"JOB_THRESHOLD": 1,
})
return get_app()
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
data = io.BytesIO()
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for n, game in enumerate(games, 1):
name = f"{n}.yaml"
zip_file.writestr(name, json.dumps({
"name": f"Player{n}",
"game": game,
game: {},
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
}))
data.seek(0)
response = app_client.post("/generate", content_type="multipart/form-data", data={
"file": (data, "yamls.zip"),
})
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
assert "Location" in response.headers, f"Starting gen failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
for attempt in range(10):
response = app_client.get(location)
if "Location" in response.headers:
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
return location[6:]
time.sleep(1)
raise TimeoutError("WebHost gen did not finish")
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={
"file": multidata.open("rb"),
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None:
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
import os
import signal
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
stop()
proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
# and ungraceful may not save the game
if proc.pid == os.getpid():
continue
if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else:
proc.kill()
try:
proc.join(30)
try:
proc.join(30)
except TimeoutError:
raise
except KeyboardInterrupt:
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
proc.join(30)
except TimeoutError:
proc.kill()
proc.join()
def stop_autogen(graceful: bool = True) -> None:
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
_stop_webhost_mp("SpawnPoolWorker-", graceful)
def stop_autohost(graceful: bool = True) -> None:
_stop_webhost_mp("MultiHoster", graceful)

View File

@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name
from worlds import AutoWorldRegister
from worlds.AutoWorld import AutoWorldRegister
assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape

View File

@@ -0,0 +1,14 @@
import unittest
from Utils import DaemonThreadPoolExecutor
class DaemonThreadPoolExecutorTest(unittest.TestCase):
def test_is_daemon(self) -> None:
def run() -> None:
pass
with DaemonThreadPoolExecutor(1) as executor:
executor.submit(run)
self.assertTrue(next(iter(executor._threads)).daemon)

View File

@@ -0,0 +1,78 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from mistune import HTMLRenderer, Markdown
from WebHostLib.markdown import ImgUrlRewriteInlineParser, render_markdown
class ImgUrlRewriteTest(unittest.TestCase):
markdown: Markdown
base_url = "/static/generated/docs/some_game"
def setUp(self) -> None:
self.markdown = Markdown(
renderer=HTMLRenderer(escape=False),
inline=ImgUrlRewriteInlineParser(self.base_url),
)
def test_relative_img_rewrite(self) -> None:
html = self.markdown("![Image](image.png)")
self.assertIn(f'src="{self.base_url}/image.png"', html)
def test_absolute_img_no_rewrite(self) -> None:
html = self.markdown("![Image](/image.png)")
self.assertIn(f'src="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_remote_img_no_rewrite(self) -> None:
html = self.markdown("![Image](https://example.com/image.png)")
self.assertIn(f'src="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_link_no_rewrite(self) -> None:
# The parser is only supposed to update images, not links.
html = self.markdown("[Link](image.png)")
self.assertIn(f'href="image.png"', html)
self.assertNotIn(self.base_url, html)
def test_absolute_link_no_rewrite(self) -> None:
html = self.markdown("[Link](/image.png)")
self.assertIn(f'href="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_auto_link_no_rewrite(self) -> None:
html = self.markdown("<https://example.com/image.png>")
self.assertIn(f'href="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_img_to_other_game(self) -> None:
html = self.markdown("![Image](../../generic/docs/image.png)")
self.assertIn(f'src="{self.base_url}/../Archipelago/image.png"', html)
class RenderMarkdownTest(unittest.TestCase):
"""Tests that render_markdown does the right thing."""
base_url = "/static/generated/docs/some_game"
def test_relative_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name, self.base_url)
self.assertIn(f'src="{self.base_url}/image.png"', html)
finally:
os.unlink(f.name)
def test_no_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name)
self.assertIn(f'src="image.png"', html)
self.assertNotIn(self.base_url, html)
finally:
os.unlink(f.name)

View File

@@ -224,7 +224,7 @@ class WebWorld(metaclass=WebWorldRegister):
tutorials: List["Tutorial"]
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide."""
theme = "grass"
theme: str = "grass"
"""Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""

View File

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

View File

@@ -5,7 +5,7 @@ import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from Utils import local_path, open_filename, is_frozen, is_kivy_running
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path
class Type(Enum):
@@ -204,6 +204,18 @@ def install_apworld(apworld_path: str = "") -> None:
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
def export_datapackage() -> None:
import json
from worlds import network_data_package
path = user_path("datapackage_export.json")
with open(path, "w") as f:
json.dump(network_data_package, f, indent=4)
open_file(path)
components: List[Component] = [
# Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
@@ -213,12 +225,12 @@ components: List[Component] = [
description="Host a generated multiworld on your computer."),
Component('Generate', 'Generate', cli=True,
description="Generate a multiworld with the YAMLs in the players folder."),
Component("Options Creator", "OptionsCreator", "ArchipelagoOptionsCreator", component_type=Type.TOOL,
description="Visual creator for Archipelago option files."),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time
Component('OoT Client', 'OoTClient',
@@ -232,8 +244,10 @@ components: List[Component] = [
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
#MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3'))
# MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')),
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL),
]
@@ -273,7 +287,8 @@ if not is_frozen():
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

@@ -122,7 +122,8 @@ for world_source in world_sources:
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames:
if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r"))
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
break
if manifest:
break

View File

@@ -9,7 +9,7 @@ from collections import Counter
from typing import TYPE_CHECKING, Any
from typing import Dict, Generator, Iterable, List, Set, Tuple, Union, final
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import sc2api_pb2 as sc_pb
from .constants import (
IS_PLACEHOLDER,

View File

@@ -4,11 +4,11 @@ from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
from worlds._sc2common.bot import logger
from s2clientprotocol import debug_pb2 as debug_pb
from s2clientprotocol import query_pb2 as query_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from s2clientprotocol import spatial_pb2 as spatial_pb
from .proto import debug_pb2 as debug_pb
from .proto import query_pb2 as query_pb
from .proto import raw_pb2 as raw_pb
from .proto import sc2api_pb2 as sc_pb
from .proto import spatial_pb2 as spatial_pb
from .data import ActionResult, ChatChannel, Race, Result, Status
from .game_data import AbilityData, GameData

View File

@@ -2,7 +2,7 @@ import platform
from pathlib import Path
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import sc2api_pb2 as sc_pb
from .player import Computer
from .protocol import Protocol

View File

@@ -7,11 +7,11 @@ https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188f
"""
import enum
from s2clientprotocol import common_pb2 as common_pb
from s2clientprotocol import data_pb2 as data_pb
from s2clientprotocol import error_pb2 as error_pb
from s2clientprotocol import raw_pb2 as raw_pb
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import common_pb2 as common_pb
from .proto import data_pb2 as data_pb
from .proto import error_pb2 as error_pb
from .proto import raw_pb2 as raw_pb
from .proto import sc2api_pb2 as sc_pb
CreateGameError = enum.Enum("CreateGameError", sc_pb.ResponseCreateGame.Error.items())

View File

@@ -15,7 +15,7 @@ import mpyq
import portpicker
from aiohttp import ClientSession, ClientWebSocketResponse
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import sc2api_pb2 as sc_pb
from .bot_ai import BotAI
from .client import Client

View File

@@ -5,7 +5,7 @@ import math
import random
from typing import TYPE_CHECKING, Iterable, List, Set, Tuple, Union
from s2clientprotocol import common_pb2 as common_pb
from .proto import common_pb2 as common_pb
if TYPE_CHECKING:
from .unit import Unit

View File

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/common.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/common.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ds2clientprotocol/common.proto\x12\x0eSC2APIProtocol\">\n\x10\x41vailableAbility\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x16\n\x0erequires_point\x18\x02 \x01(\x08\"X\n\tImageData\x12\x16\n\x0e\x62its_per_pixel\x18\x01 \x01(\x05\x12%\n\x04size\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Size2DI\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"\x1e\n\x06PointI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05\"T\n\nRectangleI\x12\"\n\x02p0\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\"\n\x02p1\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\x1f\n\x07Point2D\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"(\n\x05Point\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\x12\t\n\x01z\x18\x03 \x01(\x02\"\x1f\n\x07Size2DI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05*A\n\x04Race\x12\n\n\x06NoRace\x10\x00\x12\n\n\x06Terran\x10\x01\x12\x08\n\x04Zerg\x10\x02\x12\x0b\n\x07Protoss\x10\x03\x12\n\n\x06Random\x10\x04')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.common_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_RACE']._serialized_start=429
_globals['_RACE']._serialized_end=494
_globals['_AVAILABLEABILITY']._serialized_start=49
_globals['_AVAILABLEABILITY']._serialized_end=111
_globals['_IMAGEDATA']._serialized_start=113
_globals['_IMAGEDATA']._serialized_end=201
_globals['_POINTI']._serialized_start=203
_globals['_POINTI']._serialized_end=233
_globals['_RECTANGLEI']._serialized_start=235
_globals['_RECTANGLEI']._serialized_end=319
_globals['_POINT2D']._serialized_start=321
_globals['_POINT2D']._serialized_end=352
_globals['_POINT']._serialized_start=354
_globals['_POINT']._serialized_end=394
_globals['_SIZE2DI']._serialized_start=396
_globals['_SIZE2DI']._serialized_end=427
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/data.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/data.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bs2clientprotocol/data.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xc4\x03\n\x0b\x41\x62ilityData\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x11\n\tlink_name\x18\x02 \x01(\t\x12\x12\n\nlink_index\x18\x03 \x01(\r\x12\x13\n\x0b\x62utton_name\x18\x04 \x01(\t\x12\x15\n\rfriendly_name\x18\x05 \x01(\t\x12\x0e\n\x06hotkey\x18\x06 \x01(\t\x12\x1c\n\x14remaps_to_ability_id\x18\x07 \x01(\r\x12\x11\n\tavailable\x18\x08 \x01(\x08\x12\x32\n\x06target\x18\t \x01(\x0e\x32\".SC2APIProtocol.AbilityData.Target\x12\x15\n\rallow_minimap\x18\n \x01(\x08\x12\x16\n\x0e\x61llow_autocast\x18\x0b \x01(\x08\x12\x13\n\x0bis_building\x18\x0c \x01(\x08\x12\x18\n\x10\x66ootprint_radius\x18\r \x01(\x02\x12\x1c\n\x14is_instant_placement\x18\x0e \x01(\x08\x12\x12\n\ncast_range\x18\x0f \x01(\x02\"I\n\x06Target\x12\x08\n\x04None\x10\x01\x12\t\n\x05Point\x10\x02\x12\x08\n\x04Unit\x10\x03\x12\x0f\n\x0bPointOrUnit\x10\x04\x12\x0f\n\x0bPointOrNone\x10\x05\"J\n\x0b\x44\x61mageBonus\x12,\n\tattribute\x18\x01 \x01(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\r\n\x05\x62onus\x18\x02 \x01(\x02\"\xd7\x01\n\x06Weapon\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.SC2APIProtocol.Weapon.TargetType\x12\x0e\n\x06\x64\x61mage\x18\x02 \x01(\x02\x12\x31\n\x0c\x64\x61mage_bonus\x18\x03 \x03(\x0b\x32\x1b.SC2APIProtocol.DamageBonus\x12\x0f\n\x07\x61ttacks\x18\x04 \x01(\r\x12\r\n\x05range\x18\x05 \x01(\x02\x12\r\n\x05speed\x18\x06 \x01(\x02\"*\n\nTargetType\x12\n\n\x06Ground\x10\x01\x12\x07\n\x03\x41ir\x10\x02\x12\x07\n\x03\x41ny\x10\x03\"\x95\x04\n\x0cUnitTypeData\x12\x0f\n\x07unit_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x11\n\tavailable\x18\x03 \x01(\x08\x12\x12\n\ncargo_size\x18\x04 \x01(\r\x12\x14\n\x0cmineral_cost\x18\x0c \x01(\r\x12\x14\n\x0cvespene_cost\x18\r \x01(\r\x12\x15\n\rfood_required\x18\x0e \x01(\x02\x12\x15\n\rfood_provided\x18\x12 \x01(\x02\x12\x12\n\nability_id\x18\x0f \x01(\r\x12\"\n\x04race\x18\x10 \x01(\x0e\x32\x14.SC2APIProtocol.Race\x12\x12\n\nbuild_time\x18\x11 \x01(\x02\x12\x13\n\x0bhas_vespene\x18\x13 \x01(\x08\x12\x14\n\x0chas_minerals\x18\x14 \x01(\x08\x12\x13\n\x0bsight_range\x18\x19 \x01(\x02\x12\x12\n\ntech_alias\x18\x15 \x03(\r\x12\x12\n\nunit_alias\x18\x16 \x01(\r\x12\x18\n\x10tech_requirement\x18\x17 \x01(\r\x12\x18\n\x10require_attached\x18\x18 \x01(\x08\x12-\n\nattributes\x18\x08 \x03(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\x16\n\x0emovement_speed\x18\t \x01(\x02\x12\r\n\x05\x61rmor\x18\n \x01(\x02\x12\'\n\x07weapons\x18\x0b \x03(\x0b\x32\x16.SC2APIProtocol.Weapon\"\x86\x01\n\x0bUpgradeData\x12\x12\n\nupgrade_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x14\n\x0cmineral_cost\x18\x03 \x01(\r\x12\x14\n\x0cvespene_cost\x18\x04 \x01(\r\x12\x15\n\rresearch_time\x18\x05 \x01(\x02\x12\x12\n\nability_id\x18\x06 \x01(\r\")\n\x08\x42uffData\x12\x0f\n\x07\x62uff_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\"T\n\nEffectData\x12\x11\n\teffect_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x15\n\rfriendly_name\x18\x03 \x01(\t\x12\x0e\n\x06radius\x18\x04 \x01(\x02*\x9e\x01\n\tAttribute\x12\t\n\x05Light\x10\x01\x12\x0b\n\x07\x41rmored\x10\x02\x12\x0e\n\nBiological\x10\x03\x12\x0e\n\nMechanical\x10\x04\x12\x0b\n\x07Robotic\x10\x05\x12\x0b\n\x07Psionic\x10\x06\x12\x0b\n\x07Massive\x10\x07\x12\r\n\tStructure\x10\x08\x12\t\n\x05Hover\x10\t\x12\n\n\x06Heroic\x10\n\x12\x0c\n\x08Summoned\x10\x0b')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.data_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_ATTRIBUTE']._serialized_start=1630
_globals['_ATTRIBUTE']._serialized_end=1788
_globals['_ABILITYDATA']._serialized_start=79
_globals['_ABILITYDATA']._serialized_end=531
_globals['_ABILITYDATA_TARGET']._serialized_start=458
_globals['_ABILITYDATA_TARGET']._serialized_end=531
_globals['_DAMAGEBONUS']._serialized_start=533
_globals['_DAMAGEBONUS']._serialized_end=607
_globals['_WEAPON']._serialized_start=610
_globals['_WEAPON']._serialized_end=825
_globals['_WEAPON_TARGETTYPE']._serialized_start=783
_globals['_WEAPON_TARGETTYPE']._serialized_end=825
_globals['_UNITTYPEDATA']._serialized_start=828
_globals['_UNITTYPEDATA']._serialized_end=1361
_globals['_UPGRADEDATA']._serialized_start=1364
_globals['_UPGRADEDATA']._serialized_end=1498
_globals['_BUFFDATA']._serialized_start=1500
_globals['_BUFFDATA']._serialized_end=1541
_globals['_EFFECTDATA']._serialized_start=1543
_globals['_EFFECTDATA']._serialized_end=1627
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/debug.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/debug.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/debug.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xbb\x03\n\x0c\x44\x65\x62ugCommand\x12)\n\x04\x64raw\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.DebugDrawH\x00\x12\x34\n\ngame_state\x18\x02 \x01(\x0e\x32\x1e.SC2APIProtocol.DebugGameStateH\x00\x12\x36\n\x0b\x63reate_unit\x18\x03 \x01(\x0b\x32\x1f.SC2APIProtocol.DebugCreateUnitH\x00\x12\x32\n\tkill_unit\x18\x04 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugKillUnitH\x00\x12\x38\n\x0ctest_process\x18\x05 \x01(\x0b\x32 .SC2APIProtocol.DebugTestProcessH\x00\x12.\n\x05score\x18\x06 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugSetScoreH\x00\x12\x30\n\x08\x65nd_game\x18\x07 \x01(\x0b\x32\x1c.SC2APIProtocol.DebugEndGameH\x00\x12\x37\n\nunit_value\x18\x08 \x01(\x0b\x32!.SC2APIProtocol.DebugSetUnitValueH\x00\x42\t\n\x07\x63ommand\"\xb5\x01\n\tDebugDraw\x12\'\n\x04text\x18\x01 \x03(\x0b\x32\x19.SC2APIProtocol.DebugText\x12(\n\x05lines\x18\x02 \x03(\x0b\x32\x19.SC2APIProtocol.DebugLine\x12\'\n\x05\x62oxes\x18\x03 \x03(\x0b\x32\x18.SC2APIProtocol.DebugBox\x12,\n\x07spheres\x18\x04 \x03(\x0b\x32\x1b.SC2APIProtocol.DebugSphere\"L\n\x04Line\x12!\n\x02p0\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12!\n\x02p1\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"(\n\x05\x43olor\x12\t\n\x01r\x18\x01 \x01(\r\x12\t\n\x01g\x18\x02 \x01(\r\x12\t\n\x01\x62\x18\x03 \x01(\r\"\xa3\x01\n\tDebugText\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\x0c\n\x04text\x18\x02 \x01(\t\x12*\n\x0bvirtual_pos\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12(\n\tworld_pos\x18\x04 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\x0c\n\x04size\x18\x05 \x01(\r\"U\n\tDebugLine\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x04line\x18\x02 \x01(\x0b\x32\x14.SC2APIProtocol.Line\"x\n\x08\x44\x65\x62ugBox\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x03min\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\"\n\x03max\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"`\n\x0b\x44\x65\x62ugSphere\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12 \n\x01p\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\t\n\x01r\x18\x03 \x01(\x02\"k\n\x0f\x44\x65\x62ugCreateUnit\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\r\n\x05owner\x18\x02 \x01(\x05\x12$\n\x03pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x10\n\x08quantity\x18\x04 \x01(\r\"\x1c\n\rDebugKillUnit\x12\x0b\n\x03tag\x18\x01 \x03(\x04\"\x80\x01\n\x10\x44\x65\x62ugTestProcess\x12\x33\n\x04test\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.DebugTestProcess.Test\x12\x10\n\x08\x64\x65lay_ms\x18\x02 \x01(\x05\"%\n\x04Test\x12\x08\n\x04hang\x10\x01\x12\t\n\x05\x63rash\x10\x02\x12\x08\n\x04\x65xit\x10\x03\"\x1e\n\rDebugSetScore\x12\r\n\x05score\x18\x01 \x01(\x02\"z\n\x0c\x44\x65\x62ugEndGame\x12:\n\nend_result\x18\x01 \x01(\x0e\x32&.SC2APIProtocol.DebugEndGame.EndResult\".\n\tEndResult\x12\r\n\tSurrender\x10\x01\x12\x12\n\x0e\x44\x65\x63lareVictory\x10\x02\"\xa5\x01\n\x11\x44\x65\x62ugSetUnitValue\x12?\n\nunit_value\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.DebugSetUnitValue.UnitValue\x12\r\n\x05value\x18\x02 \x01(\x02\x12\x10\n\x08unit_tag\x18\x03 \x01(\x04\".\n\tUnitValue\x12\n\n\x06\x45nergy\x10\x01\x12\x08\n\x04Life\x10\x02\x12\x0b\n\x07Shields\x10\x03*\xb2\x01\n\x0e\x44\x65\x62ugGameState\x12\x0c\n\x08show_map\x10\x01\x12\x11\n\rcontrol_enemy\x10\x02\x12\x08\n\x04\x66ood\x10\x03\x12\x08\n\x04\x66ree\x10\x04\x12\x11\n\rall_resources\x10\x05\x12\x07\n\x03god\x10\x06\x12\x0c\n\x08minerals\x10\x07\x12\x07\n\x03gas\x10\x08\x12\x0c\n\x08\x63ooldown\x10\t\x12\r\n\ttech_tree\x10\n\x12\x0b\n\x07upgrade\x10\x0b\x12\x0e\n\nfast_build\x10\x0c')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.debug_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_DEBUGGAMESTATE']._serialized_start=1897
_globals['_DEBUGGAMESTATE']._serialized_end=2075
_globals['_DEBUGCOMMAND']._serialized_start=80
_globals['_DEBUGCOMMAND']._serialized_end=523
_globals['_DEBUGDRAW']._serialized_start=526
_globals['_DEBUGDRAW']._serialized_end=707
_globals['_LINE']._serialized_start=709
_globals['_LINE']._serialized_end=785
_globals['_COLOR']._serialized_start=787
_globals['_COLOR']._serialized_end=827
_globals['_DEBUGTEXT']._serialized_start=830
_globals['_DEBUGTEXT']._serialized_end=993
_globals['_DEBUGLINE']._serialized_start=995
_globals['_DEBUGLINE']._serialized_end=1080
_globals['_DEBUGBOX']._serialized_start=1082
_globals['_DEBUGBOX']._serialized_end=1202
_globals['_DEBUGSPHERE']._serialized_start=1204
_globals['_DEBUGSPHERE']._serialized_end=1300
_globals['_DEBUGCREATEUNIT']._serialized_start=1302
_globals['_DEBUGCREATEUNIT']._serialized_end=1409
_globals['_DEBUGKILLUNIT']._serialized_start=1411
_globals['_DEBUGKILLUNIT']._serialized_end=1439
_globals['_DEBUGTESTPROCESS']._serialized_start=1442
_globals['_DEBUGTESTPROCESS']._serialized_end=1570
_globals['_DEBUGTESTPROCESS_TEST']._serialized_start=1533
_globals['_DEBUGTESTPROCESS_TEST']._serialized_end=1570
_globals['_DEBUGSETSCORE']._serialized_start=1572
_globals['_DEBUGSETSCORE']._serialized_end=1602
_globals['_DEBUGENDGAME']._serialized_start=1604
_globals['_DEBUGENDGAME']._serialized_end=1726
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_start=1680
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_end=1726
_globals['_DEBUGSETUNITVALUE']._serialized_start=1729
_globals['_DEBUGSETUNITVALUE']._serialized_end=1894
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_start=1848
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_end=1894
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/query.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/query.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
from . import error_pb2 as s2clientprotocol_dot_error__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/query.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\x1a\x1cs2clientprotocol/error.proto\"\xf0\x01\n\x0cRequestQuery\x12\x34\n\x07pathing\x18\x01 \x03(\x0b\x32#.SC2APIProtocol.RequestQueryPathing\x12\x41\n\tabilities\x18\x02 \x03(\x0b\x32..SC2APIProtocol.RequestQueryAvailableAbilities\x12\x41\n\nplacements\x18\x03 \x03(\x0b\x32-.SC2APIProtocol.RequestQueryBuildingPlacement\x12$\n\x1cignore_resource_requirements\x18\x04 \x01(\x08\"\xce\x01\n\rResponseQuery\x12\x35\n\x07pathing\x18\x01 \x03(\x0b\x32$.SC2APIProtocol.ResponseQueryPathing\x12\x42\n\tabilities\x18\x02 \x03(\x0b\x32/.SC2APIProtocol.ResponseQueryAvailableAbilities\x12\x42\n\nplacements\x18\x03 \x03(\x0b\x32..SC2APIProtocol.ResponseQueryBuildingPlacement\"\x8a\x01\n\x13RequestQueryPathing\x12,\n\tstart_pos\x18\x01 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DH\x00\x12\x12\n\x08unit_tag\x18\x02 \x01(\x04H\x00\x12(\n\x07\x65nd_pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DB\x07\n\x05start\"(\n\x14ResponseQueryPathing\x12\x10\n\x08\x64istance\x18\x01 \x01(\x02\"2\n\x1eRequestQueryAvailableAbilities\x12\x10\n\x08unit_tag\x18\x01 \x01(\x04\"~\n\x1fResponseQueryAvailableAbilities\x12\x33\n\tabilities\x18\x01 \x03(\x0b\x32 .SC2APIProtocol.AvailableAbility\x12\x10\n\x08unit_tag\x18\x02 \x01(\x04\x12\x14\n\x0cunit_type_id\x18\x03 \x01(\r\"z\n\x1dRequestQueryBuildingPlacement\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12+\n\ntarget_pos\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x18\n\x10placing_unit_tag\x18\x03 \x01(\x04\"N\n\x1eResponseQueryBuildingPlacement\x12,\n\x06result\x18\x01 \x01(\x0e\x32\x1c.SC2APIProtocol.ActionResult')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.query_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_REQUESTQUERY']._serialized_start=110
_globals['_REQUESTQUERY']._serialized_end=350
_globals['_RESPONSEQUERY']._serialized_start=353
_globals['_RESPONSEQUERY']._serialized_end=559
_globals['_REQUESTQUERYPATHING']._serialized_start=562
_globals['_REQUESTQUERYPATHING']._serialized_end=700
_globals['_RESPONSEQUERYPATHING']._serialized_start=702
_globals['_RESPONSEQUERYPATHING']._serialized_end=742
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_start=744
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_end=794
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_start=796
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_end=922
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_start=924
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_end=1046
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_start=1048
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_end=1126
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/score.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/score.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/score.proto\x12\x0eSC2APIProtocol\"\xa8\x01\n\x05Score\x12\x33\n\nscore_type\x18\x06 \x01(\x0e\x32\x1f.SC2APIProtocol.Score.ScoreType\x12\r\n\x05score\x18\x07 \x01(\x05\x12\x33\n\rscore_details\x18\x08 \x01(\x0b\x32\x1c.SC2APIProtocol.ScoreDetails\"&\n\tScoreType\x12\x0e\n\nCurriculum\x10\x01\x12\t\n\x05Melee\x10\x02\"h\n\x14\x43\x61tegoryScoreDetails\x12\x0c\n\x04none\x18\x01 \x01(\x02\x12\x0c\n\x04\x61rmy\x18\x02 \x01(\x02\x12\x0f\n\x07\x65\x63onomy\x18\x03 \x01(\x02\x12\x12\n\ntechnology\x18\x04 \x01(\x02\x12\x0f\n\x07upgrade\x18\x05 \x01(\x02\"B\n\x11VitalScoreDetails\x12\x0c\n\x04life\x18\x01 \x01(\x02\x12\x0f\n\x07shields\x18\x02 \x01(\x02\x12\x0e\n\x06\x65nergy\x18\x03 \x01(\x02\"\x8a\n\n\x0cScoreDetails\x12\x1c\n\x14idle_production_time\x18\x01 \x01(\x02\x12\x18\n\x10idle_worker_time\x18\x02 \x01(\x02\x12\x19\n\x11total_value_units\x18\x03 \x01(\x02\x12\x1e\n\x16total_value_structures\x18\x04 \x01(\x02\x12\x1a\n\x12killed_value_units\x18\x05 \x01(\x02\x12\x1f\n\x17killed_value_structures\x18\x06 \x01(\x02\x12\x1a\n\x12\x63ollected_minerals\x18\x07 \x01(\x02\x12\x19\n\x11\x63ollected_vespene\x18\x08 \x01(\x02\x12 \n\x18\x63ollection_rate_minerals\x18\t \x01(\x02\x12\x1f\n\x17\x63ollection_rate_vespene\x18\n \x01(\x02\x12\x16\n\x0espent_minerals\x18\x0b \x01(\x02\x12\x15\n\rspent_vespene\x18\x0c \x01(\x02\x12\x37\n\tfood_used\x18\r \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x0fkilled_minerals\x18\x0e \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12<\n\x0ekilled_vespene\x18\x0f \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rlost_minerals\x18\x10 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0clost_vespene\x18\x11 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x44\n\x16\x66riendly_fire_minerals\x18\x12 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x43\n\x15\x66riendly_fire_vespene\x18\x13 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rused_minerals\x18\x14 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0cused_vespene\x18\x15 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x41\n\x13total_used_minerals\x18\x16 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12@\n\x12total_used_vespene\x18\x17 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x12total_damage_dealt\x18\x18 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12=\n\x12total_damage_taken\x18\x19 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x37\n\x0ctotal_healed\x18\x1a \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x13\n\x0b\x63urrent_apm\x18\x1b \x01(\x02\x12\x1d\n\x15\x63urrent_effective_apm\x18\x1c \x01(\x02')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.score_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_SCORE']._serialized_start=49
_globals['_SCORE']._serialized_end=217
_globals['_SCORE_SCORETYPE']._serialized_start=179
_globals['_SCORE_SCORETYPE']._serialized_end=217
_globals['_CATEGORYSCOREDETAILS']._serialized_start=219
_globals['_CATEGORYSCOREDETAILS']._serialized_end=323
_globals['_VITALSCOREDETAILS']._serialized_start=325
_globals['_VITALSCOREDETAILS']._serialized_end=391
_globals['_SCOREDETAILS']._serialized_start=394
_globals['_SCOREDETAILS']._serialized_end=1684
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/spatial.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/spatial.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import common_pb2 as s2clientprotocol_dot_common__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1es2clientprotocol/spatial.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\x88\x01\n\x17ObservationFeatureLayer\x12.\n\x07renders\x18\x01 \x01(\x0b\x32\x1d.SC2APIProtocol.FeatureLayers\x12=\n\x0fminimap_renders\x18\x02 \x01(\x0b\x32$.SC2APIProtocol.FeatureLayersMinimap\"\x9c\n\n\rFeatureLayers\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05power\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_hit_points\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x38\n\x15unit_hit_points_ratio\x18\x11 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bunit_energy\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x34\n\x11unit_energy_ratio\x18\x12 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_shields\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x35\n\x12unit_shields_ratio\x18\x13 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_density_aa\x18\x0e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_density\x18\x0f \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x65\x66\x66\x65\x63ts\x18\x14 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0ehallucinations\x18\x15 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x63loaked\x18\x16 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\'\n\x04\x62lip\x18\x17 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x62uffs\x18\x18 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x30\n\rbuff_duration\x18\x1a \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61\x63tive\x18\x19 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0e\x62uild_progress\x18\x1b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\x1c \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x1d \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bplaceholder\x18\x1e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\x90\x04\n\x14\x46\x65\x61tureLayersMinimap\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x63\x61mera\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61lerts\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"g\n\x11ObservationRender\x12&\n\x03map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07minimap\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\xbb\x02\n\rActionSpatial\x12@\n\x0cunit_command\x18\x01 \x01(\x0b\x32(.SC2APIProtocol.ActionSpatialUnitCommandH\x00\x12>\n\x0b\x63\x61mera_move\x18\x02 \x01(\x0b\x32\'.SC2APIProtocol.ActionSpatialCameraMoveH\x00\x12O\n\x14unit_selection_point\x18\x03 \x01(\x0b\x32/.SC2APIProtocol.ActionSpatialUnitSelectionPointH\x00\x12M\n\x13unit_selection_rect\x18\x04 \x01(\x0b\x32..SC2APIProtocol.ActionSpatialUnitSelectionRectH\x00\x42\x08\n\x06\x61\x63tion\"\xbe\x01\n\x18\x41\x63tionSpatialUnitCommand\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x35\n\x13target_screen_coord\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x36\n\x14target_minimap_coord\x18\x03 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x15\n\rqueue_command\x18\x04 \x01(\x08\x42\x08\n\x06target\"I\n\x17\x41\x63tionSpatialCameraMove\x12.\n\x0e\x63\x65nter_minimap\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\xda\x01\n\x1f\x41\x63tionSpatialUnitSelectionPoint\x12\x36\n\x16selection_screen_coord\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\x42\n\x04type\x18\x02 \x01(\x0e\x32\x34.SC2APIProtocol.ActionSpatialUnitSelectionPoint.Type\";\n\x04Type\x12\n\n\x06Select\x10\x01\x12\n\n\x06Toggle\x10\x02\x12\x0b\n\x07\x41llType\x10\x03\x12\x0e\n\nAddAllType\x10\x04\"s\n\x1e\x41\x63tionSpatialUnitSelectionRect\x12:\n\x16selection_screen_coord\x18\x01 \x03(\x0b\x32\x1a.SC2APIProtocol.RectangleI\x12\x15\n\rselection_add\x18\x02 \x01(\x08')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.spatial_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_OBSERVATIONFEATURELAYER']._serialized_start=82
_globals['_OBSERVATIONFEATURELAYER']._serialized_end=218
_globals['_FEATURELAYERS']._serialized_start=221
_globals['_FEATURELAYERS']._serialized_end=1529
_globals['_FEATURELAYERSMINIMAP']._serialized_start=1532
_globals['_FEATURELAYERSMINIMAP']._serialized_end=2060
_globals['_OBSERVATIONRENDER']._serialized_start=2062
_globals['_OBSERVATIONRENDER']._serialized_end=2165
_globals['_ACTIONSPATIAL']._serialized_start=2168
_globals['_ACTIONSPATIAL']._serialized_end=2483
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_start=2486
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_end=2676
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_start=2678
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_end=2751
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_start=2754
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_end=2972
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_start=2913
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_end=2972
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_start=2974
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_end=3089
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: s2clientprotocol/ui.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
's2clientprotocol/ui.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19s2clientprotocol/ui.proto\x12\x0eSC2APIProtocol\"\x86\x02\n\rObservationUI\x12,\n\x06groups\x18\x01 \x03(\x0b\x32\x1c.SC2APIProtocol.ControlGroup\x12-\n\x06single\x18\x02 \x01(\x0b\x32\x1b.SC2APIProtocol.SinglePanelH\x00\x12+\n\x05multi\x18\x03 \x01(\x0b\x32\x1a.SC2APIProtocol.MultiPanelH\x00\x12+\n\x05\x63\x61rgo\x18\x04 \x01(\x0b\x32\x1a.SC2APIProtocol.CargoPanelH\x00\x12\x35\n\nproduction\x18\x05 \x01(\x0b\x32\x1f.SC2APIProtocol.ProductionPanelH\x00\x42\x07\n\x05panel\"T\n\x0c\x43ontrolGroup\x12\x1b\n\x13\x63ontrol_group_index\x18\x01 \x01(\r\x12\x18\n\x10leader_unit_type\x18\x02 \x01(\r\x12\r\n\x05\x63ount\x18\x03 \x01(\r\"\x85\x02\n\x08UnitInfo\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\x17\n\x0fplayer_relative\x18\x02 \x01(\r\x12\x0e\n\x06health\x18\x03 \x01(\x05\x12\x0f\n\x07shields\x18\x04 \x01(\x05\x12\x0e\n\x06\x65nergy\x18\x05 \x01(\x05\x12\x1d\n\x15transport_slots_taken\x18\x06 \x01(\x05\x12\x16\n\x0e\x62uild_progress\x18\x07 \x01(\x02\x12(\n\x06\x61\x64\x64_on\x18\x08 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x12\n\nmax_health\x18\t \x01(\x05\x12\x13\n\x0bmax_shields\x18\n \x01(\x05\x12\x12\n\nmax_energy\x18\x0b \x01(\x05\"\x9d\x01\n\x0bSinglePanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x1c\n\x14\x61ttack_upgrade_level\x18\x02 \x01(\x05\x12\x1b\n\x13\x61rmor_upgrade_level\x18\x03 \x01(\x05\x12\x1c\n\x14shield_upgrade_level\x18\x04 \x01(\x05\x12\r\n\x05\x62uffs\x18\x05 \x03(\x05\"5\n\nMultiPanel\x12\'\n\x05units\x18\x01 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\"{\n\nCargoPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12,\n\npassengers\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x17\n\x0fslots_available\x18\x03 \x01(\x05\"7\n\tBuildItem\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x16\n\x0e\x62uild_progress\x18\x02 \x01(\x02\"\x9d\x01\n\x0fProductionPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12-\n\x0b\x62uild_queue\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x33\n\x10production_queue\x18\x03 \x03(\x0b\x32\x19.SC2APIProtocol.BuildItem\"\xda\x04\n\x08\x41\x63tionUI\x12;\n\rcontrol_group\x18\x01 \x01(\x0b\x32\".SC2APIProtocol.ActionControlGroupH\x00\x12\x37\n\x0bselect_army\x18\x02 \x01(\x0b\x32 .SC2APIProtocol.ActionSelectArmyH\x00\x12\x42\n\x11select_warp_gates\x18\x03 \x01(\x0b\x32%.SC2APIProtocol.ActionSelectWarpGatesH\x00\x12\x39\n\x0cselect_larva\x18\x04 \x01(\x0b\x32!.SC2APIProtocol.ActionSelectLarvaH\x00\x12\x44\n\x12select_idle_worker\x18\x05 \x01(\x0b\x32&.SC2APIProtocol.ActionSelectIdleWorkerH\x00\x12\x37\n\x0bmulti_panel\x18\x06 \x01(\x0b\x32 .SC2APIProtocol.ActionMultiPanelH\x00\x12=\n\x0b\x63\x61rgo_panel\x18\x07 \x01(\x0b\x32&.SC2APIProtocol.ActionCargoPanelUnloadH\x00\x12P\n\x10production_panel\x18\x08 \x01(\x0b\x32\x34.SC2APIProtocol.ActionProductionPanelRemoveFromQueueH\x00\x12?\n\x0ftoggle_autocast\x18\t \x01(\x0b\x32$.SC2APIProtocol.ActionToggleAutocastH\x00\x42\x08\n\x06\x61\x63tion\"\xd4\x01\n\x12\x41\x63tionControlGroup\x12\x45\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x35.SC2APIProtocol.ActionControlGroup.ControlGroupAction\x12\x1b\n\x13\x63ontrol_group_index\x18\x02 \x01(\r\"Z\n\x12\x43ontrolGroupAction\x12\n\n\x06Recall\x10\x01\x12\x07\n\x03Set\x10\x02\x12\n\n\x06\x41ppend\x10\x03\x12\x0f\n\x0bSetAndSteal\x10\x04\x12\x12\n\x0e\x41ppendAndSteal\x10\x05\")\n\x10\x41\x63tionSelectArmy\x12\x15\n\rselection_add\x18\x01 \x01(\x08\".\n\x15\x41\x63tionSelectWarpGates\x12\x15\n\rselection_add\x18\x01 \x01(\x08\"\x13\n\x11\x41\x63tionSelectLarva\"\x82\x01\n\x16\x41\x63tionSelectIdleWorker\x12\x39\n\x04type\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.ActionSelectIdleWorker.Type\"-\n\x04Type\x12\x07\n\x03Set\x10\x01\x12\x07\n\x03\x41\x64\x64\x10\x02\x12\x07\n\x03\x41ll\x10\x03\x12\n\n\x06\x41\x64\x64\x41ll\x10\x04\"\xb3\x01\n\x10\x41\x63tionMultiPanel\x12\x33\n\x04type\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.ActionMultiPanel.Type\x12\x12\n\nunit_index\x18\x02 \x01(\x05\"V\n\x04Type\x12\x10\n\x0cSingleSelect\x10\x01\x12\x10\n\x0c\x44\x65selectUnit\x10\x02\x12\x13\n\x0fSelectAllOfType\x10\x03\x12\x15\n\x11\x44\x65selectAllOfType\x10\x04\",\n\x16\x41\x63tionCargoPanelUnload\x12\x12\n\nunit_index\x18\x01 \x01(\x05\":\n$ActionProductionPanelRemoveFromQueue\x12\x12\n\nunit_index\x18\x01 \x01(\x05\"*\n\x14\x41\x63tionToggleAutocast\x12\x12\n\nability_id\x18\x01 \x01(\x05')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.ui_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_OBSERVATIONUI']._serialized_start=46
_globals['_OBSERVATIONUI']._serialized_end=308
_globals['_CONTROLGROUP']._serialized_start=310
_globals['_CONTROLGROUP']._serialized_end=394
_globals['_UNITINFO']._serialized_start=397
_globals['_UNITINFO']._serialized_end=658
_globals['_SINGLEPANEL']._serialized_start=661
_globals['_SINGLEPANEL']._serialized_end=818
_globals['_MULTIPANEL']._serialized_start=820
_globals['_MULTIPANEL']._serialized_end=873
_globals['_CARGOPANEL']._serialized_start=875
_globals['_CARGOPANEL']._serialized_end=998
_globals['_BUILDITEM']._serialized_start=1000
_globals['_BUILDITEM']._serialized_end=1055
_globals['_PRODUCTIONPANEL']._serialized_start=1058
_globals['_PRODUCTIONPANEL']._serialized_end=1215
_globals['_ACTIONUI']._serialized_start=1218
_globals['_ACTIONUI']._serialized_end=1820
_globals['_ACTIONCONTROLGROUP']._serialized_start=1823
_globals['_ACTIONCONTROLGROUP']._serialized_end=2035
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_start=1945
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_end=2035
_globals['_ACTIONSELECTARMY']._serialized_start=2037
_globals['_ACTIONSELECTARMY']._serialized_end=2078
_globals['_ACTIONSELECTWARPGATES']._serialized_start=2080
_globals['_ACTIONSELECTWARPGATES']._serialized_end=2126
_globals['_ACTIONSELECTLARVA']._serialized_start=2128
_globals['_ACTIONSELECTLARVA']._serialized_end=2147
_globals['_ACTIONSELECTIDLEWORKER']._serialized_start=2150
_globals['_ACTIONSELECTIDLEWORKER']._serialized_end=2280
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_start=2235
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_end=2280
_globals['_ACTIONMULTIPANEL']._serialized_start=2283
_globals['_ACTIONMULTIPANEL']._serialized_end=2462
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_start=2376
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_end=2462
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_start=2464
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_end=2508
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_start=2510
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_end=2568
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_start=2570
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_end=2612
# @@protoc_insertion_point(module_scope)

View File

@@ -4,7 +4,7 @@ from contextlib import suppress
from aiohttp import ClientWebSocketResponse
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import sc2api_pb2 as sc_pb
from .data import Status

View File

@@ -8,7 +8,7 @@ import traceback
from aiohttp import WSMsgType, web
from worlds._sc2common.bot import logger
from s2clientprotocol import sc2api_pb2 as sc_pb
from .proto import sc2api_pb2 as sc_pb
from .controller import Controller
from .data import Result, Status

View File

@@ -1,6 +1,6 @@
import datetime
from s2clientprotocol import score_pb2 as score_pb
from .proto import score_pb2 as score_pb
from .position import Point2

View File

@@ -1,6 +1,5 @@
s2clientprotocol>=5.0.11.90136.0
mpyq>=0.2.5
portpicker>=1.5.2
aiohttp>=3.8.4
loguru>=0.7.0
protobuf==3.20.3
protobuf==6.31.1

View File

@@ -8,7 +8,7 @@ import bsdiff4
import Utils
from settings import get_settings
from worlds.Files import APPatch, AutoPatchRegister
from worlds.Files import APPatch
from .Locations import LocationData
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
@@ -78,7 +78,7 @@ class BatNoTouchLocation:
return ret_dict
class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
class AdventureDeltaPatch(APPatch):
hash = ADVENTUREHASH
game = "Adventure"
patch_file_ending = ".apadvn"

View File

@@ -1,4 +1,6 @@
import asyncio
import time
import Utils
import websockets
import functools
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
if not ctx.is_proxy_connected():
break
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
msg["data"]["time"] = time.time()
await ctx.send_msgs([msg])
except Exception as e:

View File

@@ -243,7 +243,7 @@ guaranteed_first_acts = [
"Time Rift - Mafia of Cooks",
"Time Rift - Dead Bird Studio",
"Time Rift - Sleepy Subcon",
"Time Rift - Alpine Skyline"
"Time Rift - Alpine Skyline",
"Time Rift - Tour",
"Time Rift - Rumbi Factory",
]

View File

@@ -239,7 +239,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
multiworld.worlds[item.player].collect(all_state_base, item)
pre_fill_items = []
for player in in_dungeon_player_ids:
pre_fill_items += multiworld.worlds[player].get_pre_fill_items()
pre_fill_items += [item for item in multiworld.worlds[player].get_pre_fill_items() if not item.crystal]
for item in in_dungeon_items:
try:
pre_fill_items.remove(item)

View File

@@ -2,7 +2,7 @@ from collections import namedtuple
import logging
from BaseClasses import ItemClassification
from Fill import FillError
from Options import OptionError
from .SubClasses import ALttPLocation, LTTPRegion, LTTPRegionType
from .Shops import TakeAny, total_shop_slots, set_up_shops, shop_table_by_location, ShopType
@@ -410,15 +410,16 @@ def generate_itempool(world):
pool_count = len(items)
new_items = ["Triforce Piece" for _ in range(additional_triforce_pieces)]
if world.options.shuffle_capacity_upgrades or world.options.bombless_start:
progressive = world.options.progressive
progressive = multiworld.random.choice([True, False]) if progressive == 'grouped_random' else progressive == 'on'
progressive = world.options.progressive.want_progressives(world.random)
if world.options.shuffle_capacity_upgrades == "on_combined":
new_items.append("Bomb Upgrade (50)")
elif world.options.shuffle_capacity_upgrades == "on":
new_items += ["Bomb Upgrade (+5)"] * 6
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
if world.options.shuffle_capacity_upgrades != "on_combined" and world.options.bombless_start:
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
if world.options.bombless_start:
new_items.append("Bomb Upgrade (+5)" if progressive else "Bomb Upgrade (+10)")
elif world.options.bombless_start:
new_items.append("Bomb Upgrade (+10)")
if world.options.shuffle_capacity_upgrades and not world.options.retro_bow:
if world.options.shuffle_capacity_upgrades == "on_combined":
@@ -466,6 +467,9 @@ def generate_itempool(world):
items_were_cut = items_were_cut or cut_item(items, *reduce_item)
elif len(reduce_item) == 4:
items_were_cut = items_were_cut or condense_items(items, *reduce_item)
if reduce_item[0] == "Piece of Heart" and world.logical_heart_pieces:
world.logical_heart_pieces -= reduce_item[2]
world.logical_heart_containers += reduce_item[3]
elif len(reduce_item) == 1: # Bottles
bottles = [item for item in items if item.name in item_name_groups["Bottles"]]
if len(bottles) > 4:
@@ -476,7 +480,7 @@ def generate_itempool(world):
if items_were_cut:
break
else:
raise Exception(f"Failed to limit item pool size for player {player}")
raise OptionError(f"Failed to limit item pool size for player {player}")
if len(items) < pool_count:
items += removed_filler[len(items) - pool_count:]

View File

@@ -1197,8 +1197,8 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
0x51, 0x06, 0x52, 0xFF, # 6 +5 bomb upgrades -> +10 bomb upgrade
0x53, 0x06, 0x54, 0xFF, # 6 +5 arrow upgrades -> +10 arrow upgrade
0x58, 0x01, 0x36 if local_world.options.retro_bow else 0x43, 0xFF, # silver arrows -> single arrow (red 20 in retro mode)
0x3E, difficulty.boss_heart_container_limit, 0x47, 0xff, # boss heart -> green 20
0x17, difficulty.heart_piece_limit, 0x47, 0xff, # piece of heart -> green 20
0x3E, local_world.logical_heart_containers, 0x47, 0xff, # boss heart -> green 20
0x17, local_world.logical_heart_pieces, 0x47, 0xff, # piece of heart -> green 20
0xFF, 0xFF, 0xFF, 0xFF, # end of table sentinel
])

View File

@@ -20,7 +20,7 @@ from .StateHelpers import (can_extend_magic, can_kill_most_things,
has_fire_source, has_hearts, has_melee_weapon,
has_misery_mire_medallion, has_sword, has_turtle_rock_medallion,
has_triforce_pieces, can_use_bombs, can_bomb_or_bonk,
can_activate_crystal_switch)
can_activate_crystal_switch, can_kill_standard_start)
from .UnderworldGlitchRules import underworld_glitches_rules
@@ -1093,22 +1093,23 @@ def standard_rules(world, player):
if world.worlds[player].options.small_key_shuffle != small_key_shuffle.option_universal:
set_rule(world.get_location('Hyrule Castle - Boomerang Guard Key Drop', player),
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)
and can_kill_most_things(state, player, 2))
and can_kill_standard_start(state, player, 2))
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player),
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)
and can_kill_most_things(state, player, 1))
and can_kill_standard_start(state, player, 1))
set_rule(world.get_location('Hyrule Castle - Map Guard Key Drop', player),
lambda state: can_kill_standard_start(state, player, 1))
set_rule(world.get_location('Hyrule Castle - Big Key Drop', player),
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2))
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2)
and state.has('Big Key (Hyrule Castle)', player)
and (world.worlds[player].options.enemy_health in ("easy", "default")
or can_kill_most_things(state, player, 1)))
or can_kill_standard_start(state, player, 1)))
set_rule(world.get_location('Sewers - Key Rat Key Drop', player),
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3)
and can_kill_most_things(state, player, 1))
and can_kill_standard_start(state, player, 1))
else:
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
lambda state: state.has('Big Key (Hyrule Castle)', player))

View File

@@ -59,10 +59,11 @@ def has_hearts(state: CollectionState, player: int, count: int) -> int:
def heart_count(state: CollectionState, player: int) -> int:
# Warning: This only considers items that are marked as advancement items
diff = state.multiworld.worlds[player].difficulty_requirements
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
max_heart_pieces = state.multiworld.worlds[player].logical_heart_pieces
max_heart_containers = state.multiworld.worlds[player].logical_heart_containers
return min(state.count('Boss Heart Container', player), max_heart_containers) \
+ state.count('Sanctuary Heart Container', player) \
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
+ min(state.count('Piece of Heart', player), max_heart_pieces) // 4 \
+ 3 # starting hearts
@@ -139,6 +140,16 @@ def can_kill_most_things(state: CollectionState, player: int, enemies: int = 5)
and can_use_bombs(state, player, enemies * 4)))
def can_kill_standard_start(state: CollectionState, player: int, enemies: int = 5) -> bool:
# Enemizer does not randomize standard start enemies
return (has_melee_weapon(state, player)
or state.has('Cane of Somaria', player)
or (state.has('Cane of Byrna', player) and (enemies < 6 or can_extend_magic(state, player)))
or state.has_any(["Bow", "Progressive Bow"], player)
or state.has('Fire Rod', player)
or can_use_bombs(state, player, enemies)) # Escape assist is set
def can_get_good_bee(state: CollectionState, player: int) -> bool:
cave = state.multiworld.get_region('Good Bee Cave', player)
return (

View File

@@ -305,6 +305,8 @@ class ALTTPWorld(World):
self.required_medallions = ["Ether", "Quake"]
self.escape_assist = []
self.shops = []
self.logical_heart_containers = 10
self.logical_heart_pieces = 24
super(ALTTPWorld, self).__init__(*args, **kwargs)
@classmethod
@@ -384,6 +386,8 @@ class ALTTPWorld(World):
self.options.local_items.value |= self.dungeon_local_item_names
self.difficulty_requirements = difficulties[self.options.item_pool.current_key]
self.logical_heart_pieces = self.difficulty_requirements.heart_piece_limit
self.logical_heart_containers = self.difficulty_requirements.boss_heart_container_limit
# enforce pre-defined local items.
if self.options.goal in ["local_triforce_hunt", "local_ganon_triforce_hunt"]:

View File

@@ -88,9 +88,8 @@ You only have to do these steps once.
1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".

View File

@@ -88,9 +88,8 @@ Sólo hay que seguir estos pasos una vez.
1. Comienza en la pantalla del menú principal de RetroArch.
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
el Puerto de comandos de red.
![Captura de pantalla del ajuste Comandos de red](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
el Puerto de comandos de red. \
![Captura de pantalla del ajuste Comandos de red](../../generic/docs/retroarch-network-commands-en.png)
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
SFC (bsnes-mercury Performance)".

View File

@@ -89,9 +89,8 @@ Vous n'avez qu'à faire ces étapes qu'une fois.
1. Entrez dans le menu principal RetroArch
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
Port des commandes réseau à 555355.
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-fr.png)
Port des commandes réseau à 555355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-fr.png)
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
sélectionnez le.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,46 @@
This apworld is meant as a learning tool for new apworld devs.
It is a completely standalone resource, but there will be links to additional resources when appropriate.
#################
# Prerequisites #
#################
APQuest will only explain how to write the generation-side code for your game, not how to write a client or mod for it.
For a more zoomed out view of how to add a game to Archipelago, you can read this document:
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/adding%20games.md
APQuest assumes you already vaguely know what an apworld is.
If you don't know, read this first:
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/apworld%20specification.md
To write an apworld, you need to be running Archipelago from source (Python) instead of using e.g. the .exe build.
Here's an explanation for how to do that.
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/running%20from%20source.md
#######################
# How to read APQuest #
#######################
You'll want to start with __init__.py, then move to world.py.
If you also want to learn how to write unit tests, go to test/__init__.py.
You can ignore the game/ folder, it contains the actual game code, graphics and music.
The client/ folder is NOT meant for teaching.
While the client was written to the best of its author's ability, it does not meet the same standard as the world code.
The client code is also lacking the explanatory comments.
Copy from it at your own risk.
###################
# Further reading #
###################
APQuest is a very simple game, so not every edge case will be covered.
The world API document goes a lot more in-depth on certain topics:
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md
There is also the "APWorld dev FAQ" document with common emergent problems:
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/apworld_dev_faq.md
In general, but especially if you want your apworld to be verified by core, you should follow our style guide:
https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/style.md

View File

@@ -0,0 +1,12 @@
# The first thing you should make for your world is an archipelago.json manifest file.
# You can reference APQuest's, but you should change the "game" field (obviously),
# and you should also change the "minimum_ap_version" - probably to the current value of Utils.__version__.
# Apart from the regular apworld code that allows generating multiworld seeds with your game,
# your apworld might have other "components" that should be launchable from the Archipelago Launcher.
# You can ignore this for now. If you are specifically interested in components, you can read components.py.
from . import components as components
# The main thing we do in our __init__.py is importing our world class from our world.py to initialize it.
# Obviously, this world class needs to exist first. For this, read world.py.
from .world import APQuestWorld as APQuestWorld

View File

@@ -0,0 +1,6 @@
{
"game": "APQuest",
"minimum_ap_version": "0.6.4",
"world_version": "1.0.0",
"authors": ["NewSoupVi"]
}

View File

@@ -0,0 +1,5 @@
# !!! IMPORTANT !!!
# The client implementation is *not* meant for teaching.
# Obviously, it is written to the best of its author's abilities,
# but it is not to the same standard as the rest of the apworld.
# Copy things from here at your own risk.

View File

@@ -0,0 +1,56 @@
<ConfettiView>:
size_hint: None, None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
spacing: 0
padding: 0
<APQuestGrid>:
cols: 12
rows: 11
spacing: 0
padding: 0
size_hint: None, None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<APQuestGameView>:
RelativeLayout:
id: game_container
<APQuestControlsView>:
Label:
markup: True
font_size: "20sp"
valign: "middle"
pos_hint: {"center_x": 0.5, "center_y": 0.5}
text:
"""[b]Controls:[/b]
WASD or Arrow Keys to move
Space to attack or interact
C to fire available Confetti Cannons
Number Keys + Backspace for Math Trap\n
Rebinding controls might be added in the future :)"""
<VolumeSliderView>:
orientation: "horizontal"
size_hint: 1, None
padding: 0
height: 50
Label:
size_hint: None, 1
text: "Volume:"
Slider:
id: volume_slider
size_hint: 1, 1
min: 0
max: 100
step: 1
value: 50
orientation: "horizontal"
Label:
size_hint: None, 1
text: str(int(volume_slider.value))

View File

@@ -0,0 +1,290 @@
import asyncio
import sys
from argparse import Namespace
from enum import Enum
from typing import TYPE_CHECKING, Any
from CommonClient import CommonContext, gui_enabled, logger, server_loop
from NetUtils import ClientStatus
from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent
from ..game.game import Game
from ..game.inputs import Input
from ..game.items import Item
from ..game.locations import Location
from .game_manager import APQuestManager
from .graphics import PlayerSprite
from .item_quality import get_quality_for_network_item
from .sounds import (
CONFETTI_CANNON,
ITEM_JINGLES,
MATH_PROBLEM_SOLVED_JINGLE,
MATH_PROBLEM_STARTED_JINGLE,
VICTORY_JINGLE,
)
if TYPE_CHECKING:
import kvui
# !!! IMPORTANT !!!
# The client implementation is *not* meant for teaching.
# Obviously, it is written to the best of its author's abilities,
# but it is not to the same standard as the rest of the apworld.
# Copy things from here at your own risk.
class ConnectionStatus(Enum):
NOT_CONNECTED = 0
SCOUTS_NOT_SENT = 1
SCOUTS_SENT = 2
GAME_RUNNING = 3
class APQuestContext(CommonContext):
game = "APQuest"
items_handling = 0b111 # full remote
client_loop: asyncio.Task[None]
last_connected_slot: int | None = None
slot_data: dict[str, Any]
ap_quest_game: Game | None = None
hard_mode: bool = False
hammer: bool = False
extra_starting_chest: bool = False
player_sprite: PlayerSprite = PlayerSprite.HUMAN
connection_status: ConnectionStatus = ConnectionStatus.NOT_CONNECTED
highest_processed_item_index: int = 0
queued_locations: list[int]
delay_intro_song: bool
ui: APQuestManager
def __init__(
self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False
) -> None:
super().__init__(server_address, password)
self.queued_locations = []
self.slot_data = {}
self.delay_intro_song = delay_intro_song
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
self.ui.allow_intro_song()
await super().server_auth(password_requested)
await self.get_username()
await self.send_connect(game=self.game)
def handle_connection_loss(self, msg: str) -> None:
self.ui.allow_intro_song()
super().handle_connection_loss(msg)
async def connect(self, address: str | None = None) -> None:
self.ui.switch_to_regular_tab()
await super().connect(address)
async def apquest_loop(self) -> None:
while not self.exit_event.is_set():
if self.connection_status != ConnectionStatus.GAME_RUNNING:
if self.connection_status == ConnectionStatus.SCOUTS_NOT_SENT:
await self.send_msgs([{"cmd": "LocationScouts", "locations": self.server_locations}])
self.connection_status = ConnectionStatus.SCOUTS_SENT
await asyncio.sleep(0.1)
continue
if not self.ap_quest_game or not self.ap_quest_game.gameboard or not self.ap_quest_game.gameboard.ready:
await asyncio.sleep(0.1)
continue
try:
while self.queued_locations:
location = self.queued_locations.pop(0)
self.location_checked_side_effects(location)
self.locations_checked.add(location)
await self.check_locations({location})
rerender = False
new_items = self.items_received[self.highest_processed_item_index :]
for item in new_items:
self.highest_processed_item_index += 1
self.ap_quest_game.receive_item(item.item, item.location, item.player)
rerender = True
for new_remotely_cleared_location in self.checked_locations - self.locations_checked:
self.ap_quest_game.force_clear_location(new_remotely_cleared_location)
rerender = True
if rerender:
self.render()
if self.ap_quest_game.player.has_won and not self.finished_game:
await self.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
self.finished_game = True
except Exception as e:
logger.exception(e)
await asyncio.sleep(0.1)
def on_package(self, cmd: str, args: dict[str, Any]) -> None:
if cmd == "ConnectionRefused":
self.ui.allow_intro_song()
if cmd == "Connected":
if self.connection_status == ConnectionStatus.GAME_RUNNING:
# In a connection loss -> auto reconnect scenario, we can seamlessly keep going
return
self.last_connected_slot = self.slot
self.connection_status = ConnectionStatus.NOT_CONNECTED # for safety, it will get set again later
self.slot_data = args["slot_data"]
self.hard_mode = self.slot_data["hard_mode"]
self.hammer = self.slot_data["hammer"]
self.extra_starting_chest = self.slot_data["extra_starting_chest"]
try:
self.player_sprite = PlayerSprite(self.slot_data["player_sprite"])
except Exception as e:
logger.exception(e)
self.player_sprite = PlayerSprite.UNKNOWN
self.ap_quest_game = Game(self.hard_mode, self.hammer, self.extra_starting_chest)
self.highest_processed_item_index = 0
self.render()
self.connection_status = ConnectionStatus.SCOUTS_NOT_SENT
if cmd == "LocationInfo":
remote_item_graphic_overrides = {
Location(location): Item(network_item.item)
for location, network_item in self.locations_info.items()
if self.slot_info[network_item.player].game == self.game
}
assert self.ap_quest_game is not None
self.ap_quest_game.gameboard.fill_remote_location_content(remote_item_graphic_overrides)
self.render()
self.ui.game_view.bind_keyboard()
self.connection_status = ConnectionStatus.GAME_RUNNING
self.ui.game_started()
async def disconnect(self, *args: Any, **kwargs: Any) -> None:
self.finished_game = False
self.locations_checked = set()
self.connection_status = ConnectionStatus.NOT_CONNECTED
await super().disconnect(*args, **kwargs)
def render(self) -> None:
if self.ap_quest_game is None:
raise RuntimeError("Tried to render before self.ap_quest_game was initialized.")
self.ui.render(self.ap_quest_game, self.player_sprite)
self.handle_game_events()
def location_checked_side_effects(self, location: int) -> None:
network_item = self.locations_info[location]
if network_item.player == self.slot and network_item.item == Item.MATH_TRAP.value:
# In case of a local math trap, we only play the math trap trigger jingle
return
item_quality = get_quality_for_network_item(network_item)
self.play_jingle(ITEM_JINGLES[item_quality])
def play_jingle(self, audio_filename: str) -> None:
self.ui.play_jingle(audio_filename)
def handle_game_events(self) -> None:
if self.ap_quest_game is None:
return
while self.ap_quest_game.queued_events:
event = self.ap_quest_game.queued_events.pop(0)
if isinstance(event, LocationClearedEvent):
self.queued_locations.append(event.location_id)
continue
if isinstance(event, VictoryEvent):
self.play_jingle(VICTORY_JINGLE)
continue
if isinstance(event, ConfettiFired):
gameboard_x, gameboard_y = self.ap_quest_game.gameboard.size
gameboard_x += 1 # vertical item column
x = (event.x + 0.5) / gameboard_x
y = 1 - (event.y + 0.5) / gameboard_y # Kivy's y is bottom to top (ew)
self.ui.play_jingle(CONFETTI_CANNON)
self.ui.add_confetti((x, y), (self.slot_data["confetti_explosiveness"] + 1) * 5)
continue
if isinstance(event, MathProblemStarted):
self.play_jingle(MATH_PROBLEM_STARTED_JINGLE)
continue
if isinstance(event, MathProblemSolved):
self.play_jingle(MATH_PROBLEM_SOLVED_JINGLE)
continue
def input_and_rerender(self, input_key: Input) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
self.ap_quest_game.input(input_key)
self.render()
def make_gui(self) -> "type[kvui.GameManager]":
self.load_kv()
return APQuestManager
def load_kv(self) -> None:
import pkgutil
from kivy.lang import Builder
data = pkgutil.get_data(__name__, "ap_quest_client.kv")
if data is None:
raise RuntimeError("ap_quest_client.kv could not be loaded.")
Builder.load_string(data.decode())
async def main(args: Namespace) -> None:
if not gui_enabled:
raise RuntimeError("APQuest cannot be played without gui.")
# Assume we shouldn't play the intro song in the auto-connect scenario, because the game will instantly start.
delay_intro_song = args.connect and args.name
ctx = APQuestContext(args.connect, args.password, delay_intro_song=delay_intro_song)
ctx.auth = args.name
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
ctx.run_gui()
ctx.run_cli()
ctx.client_loop = asyncio.create_task(ctx.apquest_loop(), name="Client Loop")
await ctx.exit_event.wait()
await ctx.shutdown()
def launch(*args: str) -> None:
from .launch import launch_ap_quest_client
launch_ap_quest_client(*args)
if __name__ == "__main__":
launch(*sys.argv[1:])

View File

@@ -0,0 +1,256 @@
from collections.abc import Callable
from dataclasses import dataclass
from math import sqrt
from random import choice, random
from typing import TYPE_CHECKING, Any
from kivy.core.window import Keyboard, Window
from kivy.graphics import Color, Triangle
from kivy.graphics.instructions import Canvas
from kivy.input import MotionEvent
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivymd.uix.recycleview import MDRecycleView
from CommonClient import logger
from ..game.inputs import Input
INPUT_MAP = {
"up": Input.UP,
"w": Input.UP,
"down": Input.DOWN,
"s": Input.DOWN,
"right": Input.RIGHT,
"d": Input.RIGHT,
"left": Input.LEFT,
"a": Input.LEFT,
"spacebar": Input.ACTION,
"c": Input.CONFETTI,
"0": Input.ZERO,
"1": Input.ONE,
"2": Input.TWO,
"3": Input.THREE,
"4": Input.FOUR,
"5": Input.FIVE,
"6": Input.SIX,
"7": Input.SEVEN,
"8": Input.EIGHT,
"9": Input.NINE,
"backspace": Input.BACKSPACE,
}
class APQuestGameView(MDRecycleView):
_keyboard: Keyboard | None = None
input_function: Callable[[Input], None]
def __init__(self, input_function: Callable[[Input], None], **kwargs: Any) -> None:
super().__init__(**kwargs)
self.input_function = input_function
self.bind_keyboard()
def on_touch_down(self, touch: MotionEvent) -> None:
self.bind_keyboard()
def bind_keyboard(self) -> None:
if self._keyboard is not None:
return
self._keyboard = Window.request_keyboard(self._keyboard_closed, self)
self._keyboard.bind(on_key_down=self._on_keyboard_down)
def _keyboard_closed(self) -> None:
if self._keyboard is None:
return
self._keyboard.unbind(on_key_down=self._on_keyboard_down)
self._keyboard = None
def _on_keyboard_down(self, _: Any, keycode: tuple[int, str], _1: Any, _2: Any) -> bool:
if keycode[1] in INPUT_MAP:
self.input_function(INPUT_MAP[keycode[1]])
return True
class APQuestGrid(GridLayout):
def check_resize(self, _: int, _1: int) -> None:
parent_width, parent_height = self.parent.size
self_width_according_to_parent_height = parent_height * 12 / 11
self_height_according_to_parent_width = parent_height * 11 / 12
if self_width_according_to_parent_height > parent_width:
self.size = parent_width, self_height_according_to_parent_width
else:
self.size = self_width_according_to_parent_height, parent_height
CONFETTI_COLORS = [
(220 / 255, 0, 212 / 255), # PINK
(0, 0, 252 / 255), # BLUE
(252 / 255, 220 / 255, 0), # YELLOW
(0, 184 / 255, 0), # GREEN
(252 / 255, 56 / 255, 0), # ORANGE
]
@dataclass
class Confetti:
x_pos: float
y_pos: float
x_speed: float
y_speed: float
color: tuple[float, float, float]
life: float = 3
triangle1: Triangle | None = None
triangle2: Triangle | None = None
color_instruction: Color | None = None
def update_speed(self, dt: float) -> None:
if self.x_speed > 0:
self.x_speed -= 2.7 * dt
if self.x_speed < 0:
self.x_speed = 0
else:
self.x_speed += 2.7 * dt
if self.x_speed > 0:
self.x_speed = 0
if self.y_speed > -0.03:
self.y_speed -= 2.7 * dt
if self.y_speed < -0.03:
self.y_speed = -0.03
else:
self.y_speed += 2.7 * dt
if self.y_speed > -0.03:
self.y_speed = -0.03
def move(self, dt: float) -> None:
self.update_speed(dt)
if self.y_pos > 1:
self.y_pos = 1
self.y_speed = 0
if self.x_pos < 0.01:
self.x_pos = 0.01
self.x_speed = 0
if self.x_pos > 0.99:
self.x_pos = 0.99
self.x_speed = 0
self.x_pos += self.x_speed * dt
self.y_pos += self.y_speed * dt
def render(self, offset_x: float, offset_y: float, max_x: int, max_y: int) -> None:
if self.x_speed == 0 and self.y_speed == 0:
x_normalized, y_normalized = 0.0, 1.0
else:
speed_magnitude = sqrt(self.x_speed**2 + self.y_speed**2)
x_normalized, y_normalized = self.x_speed / speed_magnitude, self.y_speed / speed_magnitude
half_top_to_bottom = 0.006
half_left_to_right = 0.018
upwards_delta_x = x_normalized * half_top_to_bottom
upwards_delta_y = y_normalized * half_top_to_bottom
sideways_delta_x = y_normalized * half_left_to_right
sideways_delta_y = x_normalized * half_left_to_right
top_left_x, top_left_y = upwards_delta_x - sideways_delta_x, upwards_delta_y + sideways_delta_y
bottom_left_x, bottom_left_y = -upwards_delta_x - sideways_delta_x, -upwards_delta_y + sideways_delta_y
top_right_x, top_right_y = -bottom_left_x, -bottom_left_y
bottom_right_x, bottom_right_y = -top_left_x, -top_left_y
top_left_x, top_left_y = top_left_x + self.x_pos, top_left_y + self.y_pos
bottom_left_x, bottom_left_y = bottom_left_x + self.x_pos, bottom_left_y + self.y_pos
top_right_x, top_right_y = top_right_x + self.x_pos, top_right_y + self.y_pos
bottom_right_x, bottom_right_y = bottom_right_x + self.x_pos, bottom_right_y + self.y_pos
top_left_x, top_left_y = top_left_x * max_x + offset_x, top_left_y * max_y + offset_y
bottom_left_x, bottom_left_y = bottom_left_x * max_x + offset_x, bottom_left_y * max_y + offset_y
top_right_x, top_right_y = top_right_x * max_x + offset_x, top_right_y * max_y + offset_y
bottom_right_x, bottom_right_y = bottom_right_x * max_x + offset_x, bottom_right_y * max_y + offset_y
points1 = (top_left_x, top_left_y, top_right_x, top_right_y, bottom_left_x, bottom_left_y)
points2 = (bottom_right_x, bottom_right_y, top_right_x, top_right_y, bottom_left_x, bottom_left_y)
if self.color_instruction is None:
self.color_instruction = Color(*self.color)
if self.triangle1 is None:
self.triangle1 = Triangle(points=points1)
else:
self.triangle1.points = points1
if self.triangle2 is None:
self.triangle2 = Triangle(points=points2)
else:
self.triangle2.points = points2
def reduce_life(self, dt: float, canvas: Canvas) -> bool:
self.life -= dt
if self.life <= 0:
if self.color_instruction is not None:
canvas.remove(self.color_instruction)
if self.triangle1 is not None:
canvas.remove(self.triangle1)
if self.triangle2 is not None:
canvas.remove(self.triangle2)
return False
return True
class ConfettiView(MDRecycleView):
confetti: list[Confetti]
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.confetti = []
def check_resize(self, _: int, _1: int) -> None:
parent_width, parent_height = self.parent.size
self_width_according_to_parent_height = parent_height * 12 / 11
self_height_according_to_parent_width = parent_height * 11 / 12
if self_width_according_to_parent_height > parent_width:
self.size = parent_width, self_height_according_to_parent_width
else:
self.size = self_width_according_to_parent_height, parent_height
def redraw_confetti(self, dt: float) -> None:
try:
with self.canvas:
for confetti in self.confetti:
confetti.move(dt)
self.confetti = [confetti for confetti in self.confetti if confetti.reduce_life(dt, self.canvas)]
for confetti in self.confetti:
confetti.render(self.pos[0], self.pos[1], self.size[0], self.size[1])
except Exception as e:
logger.exception(e)
def add_confetti(self, initial_position: tuple[float, float], amount: int) -> None:
for i in range(amount):
self.confetti.append(
Confetti(
initial_position[0],
initial_position[1],
random() * 3.2 - 1.6 - (initial_position[0] - 0.5) * 1.2,
random() * 3.2 - 1.3 - (initial_position[1] - 0.5) * 1.2,
choice(CONFETTI_COLORS),
3 + i * 0.05,
)
)
class VolumeSliderView(BoxLayout):
pass
class APQuestControlsView(BoxLayout):
pass

View File

@@ -0,0 +1,200 @@
from __future__ import annotations
# isort: off
from kvui import GameManager, MDNavigationItemBase
# isort: on
from typing import TYPE_CHECKING, Any
from kivy.clock import Clock
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image
from kivy.uix.layout import Layout
from kivymd.uix.recycleview import MDRecycleView
from ..game.game import Game
from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView
from .graphics import PlayerSprite, get_texture
from .sounds import SoundManager
if TYPE_CHECKING:
from .ap_quest_client import APQuestContext
class APQuestManager(GameManager):
base_title = "APQuest for AP version"
ctx: APQuestContext
lower_game_grid: GridLayout
upper_game_grid: GridLayout
game_view: MDRecycleView
game_view_tab: MDNavigationItemBase
sound_manager: SoundManager
bottom_image_grid: list[list[Image]]
top_image_grid: list[list[Image]]
confetti_view: ConfettiView
bottom_grid_is_grass: bool
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.sound_manager = SoundManager()
self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song
self.top_image_grid = []
self.bottom_image_grid = []
self.bottom_grid_is_grass = False
def allow_intro_song(self) -> None:
self.sound_manager.allow_intro_to_play = True
def add_confetti(self, position: tuple[float, float], amount: int) -> None:
self.confetti_view.add_confetti(position, amount)
def play_jingle(self, audio_filename: str) -> None:
self.sound_manager.play_jingle(audio_filename)
def switch_to_tab(self, desired_tab: MDNavigationItemBase) -> None:
if self.screens.current_tab == desired_tab:
return
self.screens.current_tab.active = False
self.screens.switch_screens(desired_tab)
desired_tab.active = True
def switch_to_game_tab(self) -> None:
self.switch_to_tab(self.game_view_tab)
def switch_to_regular_tab(self) -> None:
self.switch_to_tab(self.tabs.children[-1])
def game_started(self) -> None:
self.switch_to_game_tab()
self.sound_manager.game_started = True
def render(self, game: Game, player_sprite: PlayerSprite) -> None:
self.setup_game_grid_if_not_setup(game.gameboard.size)
# This calls game.render(), which needs to happen to update the state of math traps
self.render_gameboard(game, player_sprite)
# Only now can we check whether a math problem is active
self.render_background_game_grid(game.gameboard.size, game.active_math_problem is None)
self.sound_manager.math_trap_active = game.active_math_problem is not None
self.render_item_column(game)
def render_gameboard(self, game: Game, player_sprite: PlayerSprite) -> None:
rendered_gameboard = game.render()
for gameboard_row, image_row in zip(rendered_gameboard, self.top_image_grid, strict=False):
for graphic, image in zip(gameboard_row, image_row[:11], strict=False):
texture = get_texture(graphic, player_sprite)
if texture is None:
image.opacity = 0
image.texture = None
continue
image.texture = texture
image.opacity = 1
def render_item_column(self, game: Game) -> None:
rendered_item_column = game.render_health_and_inventory(vertical=True)
for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False):
image = image_row[-1]
texture = get_texture(item_graphic)
if texture is None:
image.opacity = 0
image.texture = None
continue
image.texture = texture
image.opacity = 1
def render_background_game_grid(self, size: tuple[int, int], grass: bool) -> None:
if grass == self.bottom_grid_is_grass:
return
for row in range(size[1]):
for column in range(size[0]):
image = self.bottom_image_grid[row][column]
if not grass:
image.color = (0.3, 0.3, 0.3)
image.texture = None
continue
boss_room = (row in (0, 1, 2) and (size[1] - column) in (1, 2, 3)) or (row, column) == (3, size[1] - 2)
if boss_room:
image.color = (0.45, 0.35, 0.1)
image.texture = None
continue
image.texture = get_texture("Grass")
image.color = (1.0, 1.0, 1.0)
self.bottom_grid_is_grass = grass
def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None:
if self.upper_game_grid.children:
return
self.top_image_grid = []
self.bottom_image_grid = []
for _row in range(size[1]):
self.top_image_grid.append([])
self.bottom_image_grid.append([])
for _column in range(size[0]):
bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(bottom_image)
self.bottom_image_grid[-1].append(bottom_image)
top_image = Image(fit_mode="fill")
self.upper_game_grid.add_widget(top_image)
self.top_image_grid[-1].append(top_image)
# Right side: Inventory
image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(image)
image2 = Image(fit_mode="fill", opacity=0)
self.upper_game_grid.add_widget(image2)
self.top_image_grid[-1].append(image2)
def build(self) -> Layout:
container = super().build()
self.game_view = APQuestGameView(self.ctx.input_and_rerender)
self.game_view_tab = self.add_client_tab("APQuest", self.game_view)
controls = APQuestControlsView()
self.add_client_tab("Controls", controls)
game_container = self.game_view.ids["game_container"]
self.lower_game_grid = APQuestGrid()
self.upper_game_grid = APQuestGrid()
self.confetti_view = ConfettiView()
game_container.add_widget(self.lower_game_grid)
game_container.add_widget(self.upper_game_grid)
game_container.add_widget(self.confetti_view)
game_container.bind(size=self.lower_game_grid.check_resize)
game_container.bind(size=self.upper_game_grid.check_resize)
game_container.bind(size=self.confetti_view.check_resize)
volume_slider_container = VolumeSliderView()
volume_slider = volume_slider_container.ids["volume_slider"]
volume_slider.value = self.sound_manager.volume_percentage
volume_slider.bind(value=lambda _, new_volume: self.sound_manager.set_volume_percentage(new_volume))
self.grid.add_widget(volume_slider_container, index=3)
Clock.schedule_interval(lambda dt: self.confetti_view.redraw_confetti(dt), 1 / 60)
return container

View File

@@ -0,0 +1,181 @@
import pkgutil
from collections.abc import Buffer
from enum import Enum
from io import BytesIO
from typing import Literal, NamedTuple, cast
from bokeh.protocol import Protocol
from kivy.uix.image import CoreImage
from CommonClient import logger
from .. import game
from ..game.graphics import Graphic
# The import "from kivy.graphics.texture import Texture" does not work correctly.
# We never need the class directly, so we need to use a protocol.
class Texture(Protocol):
mag_filter: Literal["nearest"]
def get_region(self, x: int, y: int, w: int, h: int) -> "Texture": ...
class RelatedTexture(NamedTuple):
base_texture_file: str
x: int
y: int
width: int
height: int
IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
Graphic.WALL: RelatedTexture("inanimates.png", 16, 32, 16, 16),
Graphic.BREAKABLE_BLOCK: RelatedTexture("inanimates.png", 32, 32, 16, 16),
Graphic.CHEST: RelatedTexture("inanimates.png", 0, 16, 16, 16),
Graphic.BUSH: RelatedTexture("inanimates.png", 16, 16, 16, 16),
Graphic.KEY_DOOR: RelatedTexture("inanimates.png", 32, 16, 16, 16),
Graphic.BUTTON_NOT_ACTIVATED: RelatedTexture("inanimates.png", 0, 0, 16, 16),
Graphic.BUTTON_ACTIVATED: RelatedTexture("inanimates.png", 16, 0, 16, 16),
Graphic.BUTTON_DOOR: RelatedTexture("inanimates.png", 32, 0, 16, 16),
Graphic.NORMAL_ENEMY_1_HEALTH: RelatedTexture("normal_enemy.png", 0, 0, 16, 16),
Graphic.NORMAL_ENEMY_2_HEALTH: RelatedTexture("normal_enemy.png", 16, 0, 16, 16),
Graphic.BOSS_5_HEALTH: RelatedTexture("boss.png", 16, 16, 16, 16),
Graphic.BOSS_4_HEALTH: RelatedTexture("boss.png", 0, 16, 16, 16),
Graphic.BOSS_3_HEALTH: RelatedTexture("boss.png", 32, 32, 16, 16),
Graphic.BOSS_2_HEALTH: RelatedTexture("boss.png", 16, 32, 16, 16),
Graphic.BOSS_1_HEALTH: RelatedTexture("boss.png", 0, 32, 16, 16),
Graphic.EMPTY_HEART: RelatedTexture("hearts.png", 0, 0, 16, 16),
Graphic.HEART: RelatedTexture("hearts.png", 16, 0, 16, 16),
Graphic.HALF_HEART: RelatedTexture("hearts.png", 32, 0, 16, 16),
Graphic.REMOTE_ITEM: RelatedTexture("items.png", 0, 16, 16, 16),
Graphic.CONFETTI_CANNON: RelatedTexture("items.png", 16, 16, 16, 16),
Graphic.HAMMER: RelatedTexture("items.png", 32, 16, 16, 16),
Graphic.KEY: RelatedTexture("items.png", 0, 0, 16, 16),
Graphic.SHIELD: RelatedTexture("items.png", 16, 0, 16, 16),
Graphic.SWORD: RelatedTexture("items.png", 32, 0, 16, 16),
Graphic.ITEMS_TEXT: "items_text.png",
Graphic.ZERO: RelatedTexture("numbers.png", 0, 16, 16, 16),
Graphic.ONE: RelatedTexture("numbers.png", 16, 16, 16, 16),
Graphic.TWO: RelatedTexture("numbers.png", 32, 16, 16, 16),
Graphic.THREE: RelatedTexture("numbers.png", 48, 16, 16, 16),
Graphic.FOUR: RelatedTexture("numbers.png", 64, 16, 16, 16),
Graphic.FIVE: RelatedTexture("numbers.png", 0, 0, 16, 16),
Graphic.SIX: RelatedTexture("numbers.png", 16, 0, 16, 16),
Graphic.SEVEN: RelatedTexture("numbers.png", 32, 0, 16, 16),
Graphic.EIGHT: RelatedTexture("numbers.png", 48, 0, 16, 16),
Graphic.NINE: RelatedTexture("numbers.png", 64, 0, 16, 16),
Graphic.LETTER_A: RelatedTexture("letters.png", 0, 16, 16, 16),
Graphic.LETTER_E: RelatedTexture("letters.png", 16, 16, 16, 16),
Graphic.LETTER_H: RelatedTexture("letters.png", 32, 16, 16, 16),
Graphic.LETTER_I: RelatedTexture("letters.png", 0, 0, 16, 16),
Graphic.LETTER_M: RelatedTexture("letters.png", 16, 0, 16, 16),
Graphic.LETTER_T: RelatedTexture("letters.png", 32, 0, 16, 16),
Graphic.DIVIDE: RelatedTexture("symbols.png", 0, 16, 16, 16),
Graphic.EQUALS: RelatedTexture("symbols.png", 16, 16, 16, 16),
Graphic.MINUS: RelatedTexture("symbols.png", 32, 16, 16, 16),
Graphic.PLUS: RelatedTexture("symbols.png", 0, 0, 16, 16),
Graphic.TIMES: RelatedTexture("symbols.png", 16, 0, 16, 16),
Graphic.NO: RelatedTexture("symbols.png", 32, 0, 16, 16),
Graphic.UNKNOWN: RelatedTexture("symbols.png", 32, 0, 16, 16), # Same as "No"
}
BACKGROUND_TILE = RelatedTexture("inanimates.png", 0, 32, 16, 16)
class PlayerSprite(Enum):
HUMAN = 0
DUCK = 1
HORSE = 2
CAT = 3
UNKNOWN = -1
PLAYER_GRAPHICS = {
Graphic.PLAYER_DOWN: {
PlayerSprite.HUMAN: RelatedTexture("human.png", 0, 16, 16, 16),
PlayerSprite.DUCK: RelatedTexture("duck.png", 0, 16, 16, 16),
PlayerSprite.HORSE: RelatedTexture("horse.png", 0, 16, 16, 16),
PlayerSprite.CAT: RelatedTexture("cat.png", 0, 16, 16, 16),
},
Graphic.PLAYER_UP: {
PlayerSprite.HUMAN: RelatedTexture("human.png", 16, 0, 16, 16),
PlayerSprite.DUCK: RelatedTexture("duck.png", 16, 0, 16, 16),
PlayerSprite.HORSE: RelatedTexture("horse.png", 16, 0, 16, 16),
PlayerSprite.CAT: RelatedTexture("cat.png", 16, 0, 16, 16),
},
Graphic.PLAYER_LEFT: {
PlayerSprite.HUMAN: RelatedTexture("human.png", 16, 16, 16, 16),
PlayerSprite.DUCK: RelatedTexture("duck.png", 16, 16, 16, 16),
PlayerSprite.HORSE: RelatedTexture("horse.png", 16, 16, 16, 16),
PlayerSprite.CAT: RelatedTexture("cat.png", 16, 16, 16, 16),
},
Graphic.PLAYER_RIGHT: {
PlayerSprite.HUMAN: RelatedTexture("human.png", 0, 0, 16, 16),
PlayerSprite.DUCK: RelatedTexture("duck.png", 0, 0, 16, 16),
PlayerSprite.HORSE: RelatedTexture("horse.png", 0, 0, 16, 16),
PlayerSprite.CAT: RelatedTexture("cat.png", 0, 0, 16, 16),
},
}
ALL_GRAPHICS = [
BACKGROUND_TILE,
*IMAGE_GRAPHICS.values(),
*[graphic for sub_dict in PLAYER_GRAPHICS.values() for graphic in sub_dict.values()],
]
_textures: dict[str | RelatedTexture, Texture] = {}
def get_texture_by_identifier(texture_identifier: str | RelatedTexture) -> Texture:
if texture_identifier in _textures:
return _textures[texture_identifier]
if isinstance(texture_identifier, str):
image_data = pkgutil.get_data(game.__name__, f"graphics/{texture_identifier}")
if image_data is None:
raise RuntimeError(f'Could not find file "graphics/{texture_identifier}" for texture {texture_identifier}')
image_bytes = BytesIO(cast(Buffer, image_data))
texture = cast(Texture, CoreImage(image_bytes, ext="png").texture)
texture.mag_filter = "nearest"
_textures[texture_identifier] = texture
return texture
base_texture_filename, x, y, w, h = texture_identifier
base_texture = get_texture_by_identifier(base_texture_filename)
sub_texture = base_texture.get_region(x, y, w, h)
sub_texture.mag_filter = "nearest"
_textures[texture_identifier] = sub_texture
return sub_texture
def get_texture(graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None) -> Texture | None:
if graphic == Graphic.EMPTY:
return None
if graphic == "Grass":
return get_texture_by_identifier(BACKGROUND_TILE)
if graphic in IMAGE_GRAPHICS:
return get_texture_by_identifier(IMAGE_GRAPHICS[graphic])
if graphic in PLAYER_GRAPHICS:
if player_sprite is None:
raise ValueError("Tried to load a player graphic without specifying a player_sprite")
return get_texture_by_identifier(PLAYER_GRAPHICS[graphic][player_sprite])
logger.exception(f"Tried to load unknown graphic {graphic}.")
return get_texture(Graphic.UNKNOWN)

View File

@@ -0,0 +1,25 @@
from enum import Enum
from BaseClasses import ItemClassification
from NetUtils import NetworkItem
class ItemQuality(Enum):
FILLER = 0
TRAP = 1
USEFUL = 2
PROGRESSION = 3
PROGUSEFUL = 4
def get_quality_for_network_item(network_item: NetworkItem) -> ItemQuality:
flags = ItemClassification(network_item.flags)
if ItemClassification.progression in flags:
if ItemClassification.useful in flags:
return ItemQuality.PROGUSEFUL
return ItemQuality.PROGRESSION
if ItemClassification.useful in flags:
return ItemQuality.USEFUL
if ItemClassification.trap in flags:
return ItemQuality.TRAP
return ItemQuality.FILLER

View File

@@ -0,0 +1,27 @@
import asyncio
from collections.abc import Sequence
import colorama
from CommonClient import get_base_parser, handle_url_arg
# !!! IMPORTANT !!!
# The client implementation is *not* meant for teaching.
# Obviously, it is written to the best of its author's abilities,
# but it is not to the same standard as the rest of the apworld.
# Copy things from here at your own risk.
def launch_ap_quest_client(*args: Sequence[str]) -> None:
from .ap_quest_client import main
parser = get_base_parser()
parser.add_argument("--name", default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
launch_args = handle_url_arg(parser.parse_args(args))
colorama.just_fix_windows_console()
asyncio.run(main(launch_args))
colorama.deinit()

View File

@@ -0,0 +1,249 @@
import asyncio
import pkgutil
from asyncio import Task
from collections.abc import Buffer
from pathlib import Path
from typing import cast
from kivy import Config
from kivy.core.audio import Sound, SoundLoader
from CommonClient import logger
from .. import game
from .item_quality import ItemQuality
from .utils import make_data_directory
ITEM_JINGLES = {
ItemQuality.PROGUSEFUL: "8bit ProgUseful.ogg",
ItemQuality.PROGRESSION: "8bit Progression.ogg",
ItemQuality.USEFUL: "8bit Useful.ogg",
ItemQuality.TRAP: "8bit Trap.ogg",
ItemQuality.FILLER: "8bit Filler.ogg",
}
CONFETTI_CANNON = "APQuest Confetti Cannon.ogg"
MATH_PROBLEM_STARTED_JINGLE = "APQuest Math Problem Starter Jingle.ogg"
MATH_PROBLEM_SOLVED_JINGLE = "APQuest Math Problem Solved Jingle.ogg"
VICTORY_JINGLE = "8bit Victory.ogg"
ALL_JINGLES = [
MATH_PROBLEM_SOLVED_JINGLE,
MATH_PROBLEM_STARTED_JINGLE,
CONFETTI_CANNON,
VICTORY_JINGLE,
*ITEM_JINGLES.values(),
]
BACKGROUND_MUSIC_INTRO = "APQuest Intro.ogg"
BACKGROUND_MUSIC = "APQuest BGM.ogg"
MATH_TIME_BACKGROUND_MUSIC = "APQuest Math BGM.ogg"
ALL_BGM = [
BACKGROUND_MUSIC_INTRO,
BACKGROUND_MUSIC,
MATH_TIME_BACKGROUND_MUSIC,
]
ALL_SOUNDS = [
*ALL_JINGLES,
*ALL_BGM,
]
class SoundManager:
sound_paths: dict[str, Path]
jingles: dict[str, Sound]
bgm_songs: dict[str, Sound]
active_bgm_song: str = BACKGROUND_MUSIC_INTRO
current_background_music_volume: float = 1.0
background_music_target_volume: float = 0.0
background_music_task: Task[None] | None = None
background_music_last_position: int = 0
volume_percentage: int = 0
game_started: bool
math_trap_active: bool
allow_intro_to_play: bool
def __init__(self) -> None:
self.extract_sounds()
self.populate_sounds()
self.game_started = False
self.allow_intro_to_play = False
self.math_trap_active = False
self.ensure_config()
self.background_music_task = asyncio.create_task(self.sound_manager_loop())
def ensure_config(self) -> None:
Config.adddefaultsection("APQuest")
Config.setdefault("APQuest", "volume", 50)
self.set_volume_percentage(Config.getint("APQuest", "volume"))
async def sound_manager_loop(self) -> None:
while True:
self.update_background_music()
self.do_fade()
await asyncio.sleep(0.02)
def extract_sounds(self) -> None:
# Kivy appears to have no good way of loading audio from bytes.
# So, we have to extract it out of the .apworld first
sound_paths = {}
sound_directory = make_data_directory("sounds")
for sound in ALL_SOUNDS:
sound_file_location = sound_directory / sound
sound_paths[sound] = sound_file_location
if sound_file_location.exists():
continue
with open(sound_file_location, "wb") as sound_file:
data = pkgutil.get_data(game.__name__, f"audio/{sound}")
if data is None:
logger.exception(f"Unable to extract sound {sound} to Archipelago/data")
continue
sound_file.write(cast(Buffer, data))
self.sound_paths = sound_paths
def load_audio(self, sound_filename: str) -> Sound:
audio_path = self.sound_paths[sound_filename]
sound_object = SoundLoader.load(str(audio_path.absolute()))
sound_object.seek(0)
return sound_object
def populate_sounds(self) -> None:
try:
self.jingles = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_JINGLES}
except Exception as e:
logger.exception(e)
try:
self.bgm_songs = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_BGM}
for bgm_song in self.bgm_songs.values():
bgm_song.loop = True
bgm_song.seek(0)
except Exception as e:
logger.exception(e)
def play_jingle(self, audio_filename: str) -> None:
higher_priority_sound_is_playing = False
for sound_name, sound in self.jingles.items():
if higher_priority_sound_is_playing: # jingles are ordered by priority, lower priority gets eaten
sound.stop()
continue
if sound_name == audio_filename:
sound.play()
self.update_background_music()
higher_priority_sound_is_playing = True
elif sound.state == "play":
higher_priority_sound_is_playing = True
def update_background_music(self) -> None:
self.update_active_song()
if any(sound.state == "play" for sound in self.jingles.values()):
self.play_background_music(False)
else:
if self.math_trap_active:
# Don't fade math trap song, it ends up feeling better
self.play_background_music(True)
else:
self.fade_background_music(True)
def play_background_music(self, play: bool = True) -> None:
if play:
self.background_music_target_volume = 1
self.set_background_music_volume(1)
else:
self.background_music_target_volume = 0
self.set_background_music_volume(0)
def set_background_music_volume(self, volume: float) -> None:
self.current_background_music_volume = volume
for song_filename, song in self.bgm_songs.items():
if song_filename != self.active_bgm_song:
song.volume = 0
continue
song.volume = volume * self.volume_percentage / 100
def fade_background_music(self, fade_in: bool = True) -> None:
if fade_in:
self.background_music_target_volume = 1
else:
self.background_music_target_volume = 0
def set_volume_percentage(self, volume_percentage: float) -> None:
volume_percentage_int = int(volume_percentage)
if self.volume_percentage != volume_percentage:
self.volume_percentage = volume_percentage_int
Config.set("APQuest", "volume", volume_percentage_int)
Config.write()
self.set_background_music_volume(self.current_background_music_volume)
for jingle in self.jingles.values():
jingle.volume = self.volume_percentage / 100
def do_fade(self) -> None:
if self.current_background_music_volume > self.background_music_target_volume:
self.set_background_music_volume(max(0.0, self.current_background_music_volume - 0.02))
if self.current_background_music_volume < self.background_music_target_volume:
self.set_background_music_volume(min(1.0, self.current_background_music_volume + 0.02))
for song_filename, song in self.bgm_songs.items():
if song_filename != self.active_bgm_song:
if song_filename == BACKGROUND_MUSIC:
# It ends up feeling better if this just always continues playing quietly after being started.
# Even "fading in at a random spot" is better than restarting the song after a jingle / math trap.
if self.game_started and song.state == "stop":
song.play()
song.seek(0)
continue
song.stop()
song.seek(0)
continue
if self.active_bgm_song == BACKGROUND_MUSIC_INTRO and not self.allow_intro_to_play:
song.stop()
song.seek(0)
continue
if self.current_background_music_volume != 0:
if song.state == "stop":
song.play()
song.seek(0)
def update_active_song(self) -> None:
new_active_song = self.determine_correct_song()
if new_active_song == self.active_bgm_song:
return
self.active_bgm_song = new_active_song
# reevaluate song volumes
self.set_background_music_volume(self.current_background_music_volume)
def determine_correct_song(self) -> str:
if not self.game_started:
return BACKGROUND_MUSIC_INTRO
if self.math_trap_active:
return MATH_TIME_BACKGROUND_MUSIC
return BACKGROUND_MUSIC

View File

@@ -0,0 +1,21 @@
from pathlib import Path
from Utils import user_path
def make_data_directory(dir_name: str) -> Path:
root_directory = Path(user_path())
if not root_directory.exists():
raise FileNotFoundError(f"Unable to find AP directory {root_directory.absolute()}.")
data_directory = root_directory / "data"
specific_data_directory = data_directory / "apquest" / dir_name
specific_data_directory.mkdir(parents=True, exist_ok=True)
gitignore = specific_data_directory / ".gitignore"
with open(gitignore, "w") as f:
f.write("*\n")
return specific_data_directory

View File

@@ -0,0 +1,33 @@
from worlds.LauncherComponents import Component, Type, components, launch
# The most common type of component is a client, but there are other components, such as sprite/palette adjusters.
# (Note: Some worlds distribute their clients as separate, standalone programs,
# while others include them in the apworld itself. Standalone clients are not an apworld component,
# although you could make a component that e.g. auto-installs and launches the standalone client for the user.)
# APQuest has a Python client inside the apworld that contains the entire game. This is a component.
# APQuest will not teach you how to make a client or any other type of component.
# However, let's quickly talk about how you register a component to be launchable from the Archipelago Launcher.
# First, you'll need a function that takes a list of args (e.g. from the command line) that launches your component.
def run_client(*args: str) -> None:
# Ideally, you should lazily import your component code so that it doesn't have to be loaded until necessary.
from .client.launch import launch_ap_quest_client
# Also, if your component has its own lifecycle, like if it is its own window that can be interacted with,
# you should use the LauncherComponents.launch helper (which itself calls launch_subprocess).
# This will create a subprocess for your component, launching it in a separate window from the Archipelago Launcher.
launch(launch_ap_quest_client, name="APQuest Client", args=args)
# You then add this function as a component by appending a Component instance to LauncherComponents.components.
# Now, it will show up in the Launcher with its display name,
# and when the user clicks on the "Open" button, your function will be run.
components.append(
Component(
"APQuest Client",
func=run_client,
game_name="APQuest",
component_type=Type.CLIENT,
supports_uri=True,
)
)

View File

@@ -0,0 +1,78 @@
# APQuest
## Wo ist die Seite für die Einstellungen?
Die [Seite für die Spielereinstellungen dieses Spiels](../player-options) enthält alle Optionen die man benötigt, um
eine YAML-Datei zu konfigurieren und zu exportieren.
## Was ist APQuest?
APQuest ist ein Spiel, welches von NewSoupVi für Archipelago entwickelt wurde.
Es ist ein minimalistisches 8bit-inspiriertes Abenteuerspiel mit gitterförmiger Bewegungssteuerung.
APQuest ist ungefähr 20 Sekunden lang. Der Client kann aber nahtlos zwischen mehreren APQuest-Slots wechseln.
Wenn du 10 APQuest-Slots in einer Multiworld haben willst, sollte das also problemlos möglich sein.if you want to have 10 of them, that should work pretty well.
Ausschlaggebend ist bei APQuest, dass das gesamte Spiel in der .apworld enthalten ist.
Wenn du also die .apworld in deine
[Archipelago-Installation](https://github.com/ArchipelagoMW/Archipelago/releases/latest) installiert hast,
kannst du APQuest spielen.
## Warum existiert APQuest?
APQuest ist als Beispiel-.apworld geschrieben, mit welchem neue .apworld-Entwickler lernen können, wie man eine
.apworld schreibt.
Der [APQuest-Quellcode](https://github.com/NewSoupVi/Archipelago/tree/apquest/worlds/apquest) enthält unzählige Kommentare und Beispiele, die erklären,
wie jeder Teil der World-API funktioniert.
Dabei nutzt er nur die modernsten API-Funktionen (Stand: 2025-08-24).
Das sekundäre Ziel von APQuest ist, eine semi-minimale, generische .apworld zu sein, die Archipelago selbst gehört.
Damit kann sie für Archipelagos Unit-Tests benutzt werden,
ohne dass sich die Archipelago-Entwickler davor fürchten müssen, dass APQuest irgendwann gelöscht wird.
Das dritte Ziel von APQuest ist, das erste "Spiel in einer .apworld" zu sein,
wobei das ganze Spiel in Python und Kivy programmiert ist
und innerhalb seines CommonClient-basierten Clients spielbar ist.
Ich bin mir nicht ganz sicher, dass es wirklich das erste Spiel dieser Art ist, aber ich kenne bis jetzt keine anderen.
## Wenn ich mich im APQuest-Client angemeldet habe, wie spiele ich dann das Spiel?
WASD oder Pfeiltasten zum Bewegen.
Leertaste, um dein Schwert zu schwingen (wenn du es hast) und um mit Objekten zu interagieren.
C, um die Konfettikanone zu feuern.
Öffne Kisten, zerhacke Büsche, öffne Türen, aktiviere Knöpfe, besiege Gegner.
Sobald du den Drachen im oberen rechten Raum bezwingst, gewinnst du das Spiel.
Das ist alles! Viel Spaß!
## Ein Statement zum Besitz von APQuest
APQuest ist mit der [MIT-Lizenz](https://opensource.org/license/mit) lizenziert,
was heißt, dass es von jedem für jeden Zweck modifiziert und verbreitet werden kann.
Archipelago hat jedoch seine eigenen Besitztumsstrukturen, die über der MIT-Lizenz stehen.
Diese Strukturen machen es unklar,
ob eine .apworld-Implementierung überhaupt permanent verlässlich in Archipelago bleibt.
Im Zusammenhang mit diesen unverbindlichen, nicht gesetzlich verpflichtenden Besitztumsstrukturen
mache ich die folgende Aussage.
Ich, NewSoupVi, verzichte hiermit auf alle Rechte, APQuest aus Archipelago zu entfernen.
Dies bezieht sich auf alle Teile von APQuest mit der Ausnahme der Musik und der Soundeffekte.
Wenn ich die Töne entfernt haben möchte, muss ich dafür selbst einen PR öffnen.
Dieser PR darf nur die Töne entfernen und muss APQuest intakt und spielbar halten.
Solang ich der Maintainer von APQuest bin, möchte ich als solcher agieren.
Das heißt, dass jegliche Änderungen an APQuest zuerst von mir genehmigt werden müssen.
Wenn ich jedoch aufhöre, der Maintainer von APQuest zu sein,
egal ob es mein eigener Wunsch war oder ich meinen Maintainer-Verantwortungen nicht mehr nachkomme,
dann wird APQuest automatisch Eigentum der Core-Maintainer von Archipelago,
die dann frei entscheiden können, was mit APQuest passieren soll.
Es wäre mein Wunsch, dass wenn APQuest an eine andere Einzelperson übergeben wird,
diese Person sich an ähnliche Eigentumsregelungen hält wie ich.
Hoffentlich stellt dieses Statement sicher, dass APQuest für immer eine .apworld sein kann,
auf die Archipelago sich verlassen kann.
Wenn die Besitztumsstrukturen von Archipelago geändert werden,
vertraue ich den Core-Maintainern (bzw. den Eigentümern von Archipelago generell) damit,
angemessene Entscheidungen darüber zu treffen,
wie dieses Statement im Kontext der neuen Regeln interpretiert werden sollte.

View File

@@ -0,0 +1,69 @@
# APQuest
## Where is the options page?
The [player options page for this game](../player-options) contains all the options you need to configure and export a
config file.
## What is APQuest?
APQuest is an original game made entirely by NewSoupVi.
It is a minimal 8bit-era inspired adventure game with grid-like movement.
It is about 20 seconds long. However, the client can seamlessly switch between different slots,
so if you want to have 10 of them, that should work pretty well.
Crucially, this game is entirely integrated into the client sitting inside its .apworld.
If you have the .apworld installed into your [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
install, you can play APQuest.
## Why does APQuest exist?
APQuest is implemented to be an example .apworld that can be used as a learning tool for new .apworld developers.
Its [source code](https://github.com/NewSoupVi/Archipelago/tree/apquest/worlds/apquest)
contains countless comments explaining how each part of the World API works.
Also, as of the writing of this setup guide (2025-08-24), it is up to date with all the modern Archipelago APIs.
The secondary goal of APQuest is to be a semi-minimal generic world that is owned by Archipelago.
This means it can be used for Archipelago's unit tests without fear of eventual removal.
Finally, APQuest was designed to be the first ever "game inside an .apworld",
where the entire game is coded in Python and Kivy and is playable from within its CommonClient-based Client.
I'm not actually sure if it's the first, but I'm not aware of any others.
## Once I'm inside the APQuest client, how do I actually play APQuest?
WASD or Arrow Keys for movement.
Space to swing your sword (if you have it) or interact with objects.
C to fire the Confetti Cannon.
Open chests, slash bushes, open doors, press buttons, defeat enemies.
Once you beat the dragon in the top right room, you win.
That's all there is! Have fun!
## A statement on the ownership over APQuest
APQuest is licensed using the [MIT license](https://opensource.org/license/mit),
meaning it can be modified and redistributed by anyone for any purpose.
However, Archipelago has its own ownership structures built ontop of the license.
These ownership structures call into question whether any world implementation can permanently be relied on.
In terms of these non-binding, non-legal Archipelago ownership structures, I will make the following statement.
I, NewSoupVi, hereby relinquish any and all rights to remove APQuest from Archipelago.
This applies to all parts of APQuest with the sole exception of the music and sounds.
If I want the sounds to be removed, I must do so via a PR to the Archipelago repository myself.
Said PR must keep APQuest intact and playable, just with the music removed.
As long as I am the maintainer of APQuest, I wish to act as such.
This means that any updates to APQuest must go through me.
However, if I ever cease to be the maintainer of APQuest,
due to my own wishes or because I fail to uphold the maintainership "contract",
the maintainership of APQuest will go to the Core Maintainers of Archipelago, who may then decide what to do with it.
They can decide freely, but if the maintainership goes to another singular person,
it is my wish that this person adheres to a similar set of rules that I've laid out here for myself.
Hopefully, this set of commitments should ensure that APQuest will forever be an apworld that can be relied on in Core.
If the ownership structures of Archipelago change,
I trust the Core Maintainers (or the owners in general) of Archipelago to make reasonable assumptions
about how this statement should be reinterpreted to fit the new rules.

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