Compare commits

...

144 Commits

Author SHA1 Message Date
NewSoupVi
9657f92932 Update docs/world maintainer.md
Co-authored-by: RoobyRoo <thegreenrobby@gmail.com>
2026-02-09 00:06:59 +01:00
NewSoupVi
35e667791f Update docs/world maintainer.md
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-02-08 20:49:03 +01:00
NewSoupVi
ce7c54ef9d Docs: Add ability for world maintainers to opt into allowing core to merge small PRs without their approval 2025-12-02 00:14:15 +01:00
black-sliver
ac84b272c5 CI: update appimage runtime to fix problems with sleep (#5706)
also updates appimagetool.
Old tool should be compatible, but there are 2 bug fixes in it.
2025-12-01 01:25:06 +01:00
Phaneros
e8a63abfa4 weights: Fixing negatives and zeroes disappearing from option dicts updated by triggers (#5677) 2025-11-30 13:36:36 +01:00
Silvris
3fa2745c37 OptionCreator: pre-RC1 fixes (#5680)
* fix str default on text choice

* fix range with default random

* forgot module update

* handle namedrange default special

* handle option group of options we should not render

* Update OptionsCreator.py

* Update OptionsCreator.py

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

* fix Python 3.8 compatibility
`bisect_right`

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

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

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

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

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

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

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

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

* Update __init__.py

* update wording

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

* Add spaces

* Missed one

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

* Revert "Add them"

This reverts commit 82be86191f.

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

* Update Build APWorld.run.xml

---------

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

* Revision on priority

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

* update per vi feedback

* Trying a 2 sentence approach

* more details!

* Update options api.md

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

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

* slightly change it

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

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

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

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

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

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

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

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

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

* Update worlds/apquest/client/custom_views.py

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

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

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

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

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

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

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

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

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

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

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

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

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

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

* more words in options.py

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

* Less confusing wording about rules, hopefully?

* victory -> completion

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

* access rule performance

* Make all imports module-level in world.py

* formatting

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

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

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

* subclass instead of override

* I forgor to remove these

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

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

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

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

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

---------

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

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

* comments

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

The current flow of accessibility works like this:

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

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

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

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

* Reword

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

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

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

* Pycharm pls

* Violet pls

* Remove OptionsType

---------

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

* small grammar

* suggested fix

client is die (sadge)

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

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

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

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

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

* Update Generate.py

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

* change to an elif to avoid unnecessary nesting

---------

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

* Fix editorial issues in documentations

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

* added in non-common-client version clarification

* Update docs/network protocol.md

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

---------

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

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

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

* Multiple: optimize EN RA network commands screenshot

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

* WebHost, Worlds: fix retroarch images not showing

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

Guides now also properly render on Github.

* Factorio: optimize screenshots

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

* Factorio: change guide screenshots to use relative URL

* Test: markdown: fix tests on Windows

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

* Test: markdown: fix typo

I hope that's it now. *sigh*

* Landstalker: fix doc images not showing

Change to relative img urls.

* Landstalker: optimize doc PNGs

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

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

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

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

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

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

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

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

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

* Update test/general/test_world_manifest.py

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

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

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

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

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

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

* accidentally added line

* WebHost: fix some typing

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

* Test: Hosting: also test autogen

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

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

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

---------

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

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

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

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

* WebHost: fix shutting down autogen

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

* WebHost: properly propagate JOB_TIME

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

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

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

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

* oops that's wrong

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

* Update worlds/ff1/data/locations.json

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

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

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

---------

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

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

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

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

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

* SC2: fix incorrect evil logic preset option

---------

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

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

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

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

* x y z for world version

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

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

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

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

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

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

* Update CommonClient.py

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

* Revert "init"

This reverts commit bba6b7a306.

* put it back but clean

* pass args

* windows stuff

* delete old exe

this seems like it?

* use marin icon in launcher

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

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

* Store /option changes to countdown_mode in save file

* Wording changes in host.yaml

* Use .get

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

* clarity

* expand example

* clarify

* correct

* Correct

* elaborate on what version is

* Add where the apworlds are output

* authors & update versions

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md
2025-10-09 20:23:21 +02:00
297 changed files with 9662 additions and 2293 deletions

View File

@@ -9,22 +9,25 @@ on:
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
- '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch:
env:
ENEMIZER_VERSION: 7.1
# 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_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
@@ -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,10 +11,11 @@ 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_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'
@@ -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

@@ -12,8 +12,8 @@
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />

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

10
CommonClient.py Normal file → Executable file
View File

@@ -323,7 +323,7 @@ class CommonContext:
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
"""Current available Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
@@ -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
@@ -342,7 +347,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
@@ -356,13 +363,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
# using the same .yaml, so ensure that the new value is a copy.
cleaned_value = copy.deepcopy(new_weights[option])
cleaned_weights[option_name] = cleaned_value
new_options = set(cleaned_weights) - set(weights)
weights.update(cleaned_weights)
if new_options:
@@ -385,6 +397,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[
@@ -490,7 +493,7 @@ class Context:
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
@@ -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)
@@ -1517,6 +1545,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
default = ()
supports_weighting = False
display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
@@ -1724,11 +1753,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:

674
OptionsCreator.py Normal file
View File

@@ -0,0 +1,674 @@
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
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))
if option.default in option.special_range_names:
# value can get mismatched in this case
box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
option.range_end)
box.range.tag.text = str(int(box.range.slider.value))
box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
items = [
{
"text": choice.title(),
"on_release": lambda text=choice.title(): set_value(text, box)
}
for choice in option.special_range_names
]
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
box.choice.bind(on_release=open_dropdown)
self.options[name] = option.default
return box
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
text = VisualFreeText(option=option, name=name)
def set_value(instance):
self.options[name] = instance.text
text.bind(on_text_validate=set_value)
return text
def create_choice(self, option: typing.Type[Choice], name: str):
def set_button_text(button: VisualChoice, text: str):
button.text.text = text
def set_value(text, value):
set_button_text(main_button, text)
self.options[name] = value
dropdown.dismiss()
def open_dropdown(button):
# for some reason this fixes an issue causing some to not open
dropdown.open()
default_string = isinstance(option.default, str)
main_button = VisualChoice(option=option, name=name)
main_button.bind(on_release=open_dropdown)
items = [
{
"text": option.get_option_name(choice),
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
}
for choice in option.name_lookup
]
dropdown = MDDropdownMenu(caller=main_button, items=items)
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
return main_button
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
def set_button_text(button: MDButton, text: str):
for child in button.children:
if isinstance(child, MDButtonText):
child.text = text
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
text=self.create_free_text(option, name))
def set_value(instance):
set_button_text(box.choice, "Custom")
self.options[name] = instance.text
box.text.bind(on_text_validate=set_value)
return box
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
def set_value(instance: MDIconButton):
if instance.icon == "checkbox-outline":
instance.icon = "checkbox-blank-outline"
else:
instance.icon = "checkbox-outline"
self.options[name] = bool(not self.options[name])
self.options[name] = bool(option.default)
checkbox = VisualToggle(option=option, name=name)
checkbox.button.bind(on_release=set_value)
return checkbox
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
name: str, world: typing.Type[World]):
valid_keys = sorted(option.valid_keys)
if option.verify_item_name:
valid_keys += list(world.item_name_to_id.keys())
if option.verify_location_name:
valid_keys += list(world.location_name_to_id.keys())
if not issubclass(option, OptionCounter):
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name].append(getattr(list_item.text, "text"))
dialog.dismiss()
else:
def apply_changes(button):
self.options[name].clear()
for list_item in dialog.scrollbox.layout.children:
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
dialog.dismiss()
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
dialog.ids.container.spacing = dp(30)
dialog.scrollbox.layout.theme_bg_color = "Custom"
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
dialog.scrollbox.layout.spacing = dp(5)
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
if name not in self.options:
# convert from non-mutable to mutable
# We use list syntax even for sets, set behavior is enforced through GUI
if issubclass(option, OptionCounter):
self.options[name] = deepcopy(option.default)
else:
self.options[name] = sorted(option.default)
if issubclass(option, OptionCounter):
for value in sorted(self.options[name]):
dialog.add_set_item(value, self.options[name].get(value, None))
else:
for value in sorted(self.options[name]):
dialog.add_set_item(value)
dialog.save.bind(on_release=apply_changes)
dialog.open()
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
return main_button
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
tooltip = filter_tooltip(option.__doc__)
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
label_box = MDBoxLayout(orientation="horizontal")
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
label_anchor.add_widget(option_label)
label_box.add_widget(label_anchor)
option_base.add_widget(label_box)
if issubclass(option, NamedRange):
option_base.add_widget(self.create_named_range(option, name))
elif issubclass(option, Range):
option_base.add_widget(self.create_range(option, name))
elif issubclass(option, Toggle):
option_base.add_widget(self.create_toggle(option, name))
elif issubclass(option, TextChoice):
option_base.add_widget(self.create_text_choice(option, name))
elif issubclass(option, Choice):
option_base.add_widget(self.create_choice(option, name))
elif issubclass(option, FreeText):
option_base.add_widget(self.create_free_text(option, name))
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
else:
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
"Please edit your yaml manually to set this option."))
if option_can_be_randomized(option):
def randomize_option(instance: Widget, value: str):
value = value == "down"
if value:
self.options[name] = "random-" + str(self.options[name])
else:
self.options[name] = self.options[name].replace("random-", "")
if self.options[name].isnumeric() 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():
options = [(name, option) for name, option in options
if name and option.visibility & Visibility.simple_ui]
if not options:
continue # Game Options can be empty if every other option is in another group
# Can also have an option group of options that should not render on simple ui
group_item = MDExpansionPanel(size_hint_y=None)
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
TrailingPressedIconButton(icon="chevron-right",
on_release=lambda x,
item=group_item:
self.tap_expansion_chevron(
item, x)),
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
theme_bg_color="Custom",
on_release=lambda x, item=group_item:
self.tap_expansion_chevron(item, x)))
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
padding=[dp(12), dp(100), dp(12), 0],
spacing=dp(3))
group_item.add_widget(group_header)
group_item.add_widget(group_content)
group_box = ScrollBox()
group_box.layout.orientation = "vertical"
group_box.layout.spacing = dp(3)
for name, option in options:
group_content.add_widget(self.create_option(option, name, cls))
expansion_box.layout.add_widget(group_item)
self.option_layout.add_widget(expansion_box)
self.game_label.text = f"Game: {self.current_game}"
@staticmethod
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
if isinstance(chevron, MDListItem):
chevron = next((child for child in chevron.ids.trailing_container.children
if isinstance(child, TrailingPressedIconButton)), None)
panel.open() if not panel.is_open else panel.close()
if chevron:
panel.set_chevron_down(
chevron
) if not panel.is_open else panel.set_chevron_up(chevron)
def build(self):
self.set_colors()
self.options = {}
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
self.root = self.container
self.main_layout = self.container.ids.main
self.scrollbox = self.container.ids.scrollbox
def world_button_action(world_btn: WorldButton):
if self.current_game != world_btn.world_cls.game:
old_button = next((button for button in self.scrollbox.layout.children
if button.world_cls.game == self.current_game), None)
if old_button:
old_button.state = "normal"
else:
world_btn.state = "down"
self.create_options_panel(world_btn)
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
if 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

@@ -959,7 +959,7 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids = {
"Present": list(range(1337000, 1337085)),
"Past": list(range(1337086, 1337175)),
"Past": list(range(1337086, 1337157)) + list(range(1337159, 1337175)),
"Ancient Pyramid": [
1337236,
1337246, 1337247, 1337248, 1337249]
@@ -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

View File

@@ -289,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)

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

View File

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

@@ -269,7 +269,8 @@ placed on them.
### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
the pool.
the pool. Progression items without a deprioritized flag will be used first when filling priority_locations. Progression items with
a deprioritized flag will be used next.
### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between

View File

@@ -21,6 +21,29 @@ Unless these are shared between multiple people, we expect the following from ea
of development.
* Let us know of long periods of unavailability.
## Authority
For a Pull Request into a world to be merged, one of the world maintainers of that world has to approve it.
This applies to all Pull Requests, no matter how small, with the sole exception of patching security vulnerabilities.
World maintainers can partially opt out of this,
allowing core maintainers to merge pull requests which they deem critical and "obvious" enough.
There is no one singular definition of what Pull Requests fit this criteria -
You are trusting the core maintainers of Archipelago to be reasonable about their judgement.
Some examples of Pull Requests like this include:
- Fixing a broken link in documentation
- Correcting a typo
- Fixing a crash where the intent of the code is obvious (e.g. an indentation error due to typing 3 spaces instead of 4)
To do this, they can add a comment in [CODEOWNERS](./CODEOWNERS) under their game:
```
# APQuest
# Core is allowed to merge some types of PRs without my approval as described in "world maintainer.md"
/worlds/apquest/ @NewSoupVi
```
## Becoming a World Maintainer
### Adding a World

View File

@@ -525,7 +525,7 @@ def randomize_entrances(
running_time = time.perf_counter() - start_time
if running_time > 1.0:
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}, "
f"named {world.multiworld.player_name[world.player]}")
return er_state

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:

View File

@@ -1,17 +1,17 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.2
jellyfish>=1.1.3
PyYAML>=6.0.3
jellyfish>=1.2.1
jinja2>=3.1.6
schema>=0.7.7
schema>=0.7.8
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15
typing_extensions>=4.12.2
pyshortcuts>=1.9.1
platformdirs>=4.5.0
certifi>=2025.11.12
cython>=3.2.1
cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0

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,14 +390,15 @@ 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"
f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:

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
@@ -44,19 +44,19 @@ class TestOptions(unittest.TestCase):
}],
[{
"name": "ItemLinkGroup",
"item_pool": ["Hammer", "Bow"],
"item_pool": ["Hammer", "Sword"],
"link_replacement": False,
"replacement_item": None,
}]
]
# we really need some sort of test world but generic doesn't have enough items for this
world = AutoWorldRegister.world_types["A Link to the Past"]
world = AutoWorldRegister.world_types["APQuest"]
plando_options = PlandoOptions.from_option_string("bosses")
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
for link in item_links:
link.verify(world, "tester", plando_options)
self.assertIn("Hammer", link.value[0]["item_pool"])
self.assertIn("Bow", link.value[0]["item_pool"])
self.assertIn("Sword", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items
@@ -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

@@ -37,3 +37,23 @@ class TestPlayerOptions(unittest.TestCase):
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"])
def test_update_dict_supports_negatives_and_zeroes(self):
original_options = {
"dict_1": {"a": 1, "b": -1},
"dict_2": {"a": 1, "b": -1},
}
new_weights = Generate.update_weights(
original_options,
{
"+dict_1": {"a": -2, "b": 2},
"-dict_2": {"a": 1, "b": 2},
},
"Tested",
"",
)
self.assertEqual(new_weights["dict_1"]["a"], -1)
self.assertEqual(new_weights["dict_1"]["b"], 1)
self.assertEqual(new_weights["dict_2"]["a"], 0)
self.assertEqual(new_weights["dict_2"]["b"], -3)
self.assertIn("a", new_weights["dict_2"])

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:
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["APQuest"], ["Temp World"], ["APQuest", "Temp World"]]
p1_games: list[str] = []
data_paths: list[Path | None] = []
rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "Temp World")
copy_world("APQuest", "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
@@ -20,7 +20,7 @@ def copy(src: str, dst: str) -> None:
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
if (not src_cls.__file__.endswith(("__init__.py", "world.py")) or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
@@ -28,11 +28,14 @@ def copy(src: str, dst: str) -> None:
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
for potential_world_class_file in ("__init__.py", "world.py"):
with open(dst_folder / potential_world_class_file, "r", encoding="utf-8-sig") as f:
contents = f.read()
r_src = re.escape(src)
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + r_src + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:

View File

@@ -2,8 +2,8 @@ description: Almost blank test yaml
name: Player{NUMBER}
game:
Timespinner: 1 # what else
APQuest: 1 # what else
requires:
version: 0.2.6
Timespinner: {}
APQuest: {}

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

@@ -1,14 +1,26 @@
from __future__ import annotations
import abc
from bisect import bisect_right
from dataclasses import dataclass
import enum
import logging
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
from typing import (TYPE_CHECKING, Any, ClassVar, Dict, Generic, Iterable,
Optional, Sequence, Tuple, TypeGuard, TypeVar, Union)
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING:
from SNIClient import SNIContext
SNES_READ_CHUNK_SIZE = 2048
"""
note: SNI v0.0.101 currently has a bug where reads from
RetroArch >2048 bytes will only return the last ~2048 bytes read.
https://github.com/alttpo/sni/issues/51
"""
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component)
@@ -91,3 +103,119 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
""" override this with code to handle packages from the server """
pass
@dataclass(frozen=True, slots=True, order=True)
class Read:
""" snes memory read - address and size in bytes """
address: int
size: int
@dataclass(frozen=True, slots=True)
class _MemRead:
location: Read
data: bytes
_T_Enum = TypeVar("_T_Enum", bound=enum.Enum)
class SnesData(Generic[_T_Enum]):
_ranges: Sequence[_MemRead]
""" sorted by address """
def __init__(self, ranges: Sequence[tuple[Read, bytes]]) -> None:
self._ranges = [_MemRead(r, d) for r, d in ranges]
def get(self, read: _T_Enum) -> bytes:
assert isinstance(read.value, Read), read.value
address = read.value.address
index = bisect_right(self._ranges, address, key=lambda r: r.location.address) - 1
assert index >= 0, (self._ranges, read.value)
mem_read = self._ranges[index]
sub_index = address - mem_read.location.address
return mem_read.data[sub_index:sub_index + read.value.size]
class SnesReader(Generic[_T_Enum]):
"""
how to use:
```
from enum import Enum
from worlds.AutoSNIClient import Read, SNIClient, SnesReader
class MyGameMemory(Enum):
game_mode = Read(WRAM_START + 0x0998, 1)
send_queue = Read(SEND_QUEUE_START, 8 * 127)
...
snes_reader = SnesReader(MyGameMemory)
snes_data = await snes_reader.read(ctx)
if snes_data is None:
snes_logger.info("error reading from snes")
return
game_mode = snes_data.get(MyGameMemory.game_mode)
```
"""
_ranges: Sequence[Read]
""" sorted by address """
def __init__(self, reads: type[_T_Enum]) -> None:
self._ranges = self._make_ranges(reads)
@staticmethod
def _make_ranges(reads: type[enum.Enum]) -> Sequence[Read]:
unprocessed_reads: list[Read] = []
for e in reads:
assert isinstance(e.value, Read), (reads.__name__, e, e.value)
unprocessed_reads.append(e.value)
unprocessed_reads.sort()
ranges: list[Read] = []
for read in unprocessed_reads:
# v end of the previous range
if len(ranges) == 0 or read.address - (ranges[-1].address + ranges[-1].size) > 255:
ranges.append(read)
else: # combine with previous range
chunk_address = ranges[-1].address
assert read.address >= chunk_address, "sort() didn't work? or something"
original_chunk_size = ranges[-1].size
new_size = max((read.address + read.size) - chunk_address,
original_chunk_size)
ranges[-1] = Read(chunk_address, new_size)
logging.debug(f"{len(ranges)=} {max(r.size for r in ranges)=}")
return ranges
async def read(self, ctx: "SNIContext") -> SnesData[_T_Enum] | None:
"""
returns `None` if reading fails,
otherwise returns the data for the registered `Enum`
"""
from SNIClient import snes_read
reads: list[tuple[Read, bytes]] = []
for r in self._ranges:
if r.size < SNES_READ_CHUNK_SIZE: # most common
response = await snes_read(ctx, r.address, r.size)
if response is None:
return None
reads.append((r, response))
else: # big read
# Problems were reported with big reads,
# so we chunk it into smaller pieces.
read_so_far = 0
collection: list[bytes] = []
while read_so_far < r.size:
remaining_size = r.size - read_so_far
chunk_size = min(SNES_READ_CHUNK_SIZE, remaining_size)
response = await snes_read(ctx, r.address + read_so_far, chunk_size)
if response is None:
return None
collection.append(response)
read_so_far += chunk_size
reads.append((r, b"".join(collection)))
return SnesData(reads)

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,14 +287,15 @@ 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"
f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
@@ -303,5 +318,5 @@ if not is_frozen():
open_folder(apworlds_folder)
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

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
@@ -263,7 +263,6 @@ def generate_itempool(world):
('Frog', 'Get Frog'),
('Missing Smith', 'Return Smith'),
('Floodgate', 'Open Floodgate'),
('Agahnim 1', 'Beat Agahnim 1'),
('Flute Activation Spot', 'Activated Flute'),
('Capacity Upgrade Shop', 'Capacity Upgrade Shop')
]
@@ -410,15 +409,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 +466,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 +479,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

@@ -183,7 +183,7 @@ def check_enemizer(enemizercli):
if getattr(check_enemizer, "done", None):
return
if not os.path.exists(enemizercli) and not os.path.exists(enemizercli + ".exe"):
raise Exception(f"Enemizer not found at {enemizercli}, please install it."
raise Exception(f"Enemizer not found at {enemizercli}, please install it. "
f"Such as https://github.com/Ijwu/Enemizer/releases")
with check_lock:
@@ -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.1",
"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 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

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