Compare commits

..

60 Commits

Author SHA1 Message Date
Fabian Dill e85f190dfd Merge branch 'main' into core_filler_creation_reason 2026-04-21 00:34:52 +02:00
NewSoupVi dc911399fb The Witness: More control over progressive symbols (#3961)
* More control over progressive symbols!

* Cleanup crew

* lol

* Make it configurable how second stage items act on their own

* only let independent symbols work if they're not progressive

* revert defaults

* Better description

* comment for reviewers

* A complicated docstring for a complicated function

* More accurate

* This probably works more generically

* Actually, this would cause other issues anyway, so no need to make this generic yet

* :/

* oops

* Change the system to use collect/remove override so that plando and start inventory work correctly

* Vi stop doing that thing challenge

* Make SecondStateSymbolsActIndependently an OptionSet

* jank

* oop

* this is why we make unit tests I guess

* More unit tests

* More unit tests

* Add note about the absence of Rotated Shapers from the independent symbols optionset

* More verbose description

* More verbose description

* slight reword

* Ruff ruff :3 I am a good puppy <3

* Remove invis dots

* oops

* Remove some unused symbols

* display name

* linecounting -> discard

* Make all progressive chains always work

* Unfortunately, this optimisation is now unsafe with plando :(

* oops

* This is now a possible optimisation

* optimise optimise optimise

* optimise optimise optimise

* fix

* ruff

* oh

* fixed frfr

* mypy

* Clean up the tests a bit

* oop

* I actually like this better now, so I'm doing it as default

* Put stuff on the actual item class for faster collect/remove

* giga oops

* Make proguseful work

* None item left beef

* unnecessary change

* formatting

* add proguseful test to progressive symbols tests

* add proguseful test to progressive symbols tests

* clean up collect/remove a bit more

* giga lmfao

* Put stuff in option groups

* Add the new symbol items to hint system

* bump req client version

* fix soft conflict in unit tests

* fix more merge errors
2026-04-18 18:07:23 +02:00
NewSoupVi 0b38065123 APQuest: Fix Easy Mode boss having too much health to beat (#6146)
* Change the boss' health to 2 in easy mode, adjust boss graphics to reflect this behavior

* graphics formatting
2026-04-18 17:08:21 +02:00
NewSoupVi a6c1347102 The Witness: Make "Early Good Item" an OptionSet allowing Symbols, Doors and Obelisk Keys (#3804)
* New solution to that plando issue

* better

* Another warning

* better comment

* Best of both worlds I guess?

* oops

* Smarter code reuse

* better comment

* oop

* lint

* mypy

* player_name

* add unit test

* oh

* Rebrand time baby

* This fits on one line

* oop

* Update worlds/witness/__init__.py

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

* mypy

* Reorganize some doors according to medic's suggestions

* This should make it much faster (Thanks Medic)

* Town Doors works because of Church being a check always, Church Entry feels really bad tho

* Only add Desert Entry if there are no control panels stopping you

* No overlap here, so why is it a set

* Idk everything's kinda good without symbol shuffle

* Just make sure, I guess

* This makes way more sense doesn't it

* Oh, this is probably important

* oop

* loc

* oops 2

* ruff

* that was already in there

* Change the door picking a bit further

* some renaming

* slight wording change

* Fix

* Move it all to a new file

* ruff

* mypy

* .

* Make sure we're only adding as many tutorial checks as necessary

* ruff

* These checks aren't necessary, as the final list gets culled to only existing items anyway. It saves CPU cycles, but this is nicer for future compatibility

* Special handling for caves shortcuts

* 120 chars

* Update worlds/witness/place_early_item.py

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

* Clean up Windmill & Theater cases

* Make early_symbol_item removed instead

* Add early_good_item to presets

* replace double None checks with casts

* That doesn't exist anymore

* Mypy thing

* Update the doors again a bit

* Pycharm pls

* ruff

* forgot one

* oop

* Is it finally right?

* Update options.py

* Fix with new Panel Keys

* Hopefully fix crash when one of the types runs out when the others haven't yet

* oops

* Medic suggestion

* unused import

* Update place_early_item.py

* Update __init__.py

* Update __init__.py

* Update options.py

* Add possible types to option desc

* Make that include all Tutorial (Inside) checks

* Update __init__.py

* Update test_early_good_item.py

* Update test_early_good_item.py

* Slight cleanup

* fix no tutorial locations being picked up by tutorial location size check

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2026-04-18 13:52:38 +02:00
NewSoupVi 0a742b6c98 Options: Add more Option unit tests, add generic Option.__eq__, make cull_zeroes available to all OptionCounters #5905 2026-04-18 13:49:19 +02:00
NewSoupVi 66712bbd87 APQuest: Switch to Rule Builder (#5906)
* CachedRuleBuilderWorld

* CachedRuleBuilderWorld

* APQuest Rule Builder finished

* Added comment

Add a rule to check if the player has a Sword to destroy bushes.

* Bump version + typo fix

* Update worlds/apquest/rules.py

Co-authored-by: Ian Robinson <drtchops@users.noreply.github.com>

* Address Tchops' review comments

---------

Co-authored-by: Ian Robinson <drtchops@users.noreply.github.com>
2026-04-18 13:48:32 +02:00
black-sliver 5f9e38b783 Test: add test for unpickling NetUtils enums (#5326)
* Test: add test for unpickling NetUtils enums

This verifies that Utils.ByValue either works or is not required,
and once we drop ByValue, this validates that future Python
versions do not break our Enums again.

* Test: NetUtils enum switch to more direct pickle.dumps

It's probably better to use the direct interface in case
restricted_dumps does some funky stuff in the future.

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

* Test: NetUtils enum fix import for change

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-04-18 02:15:37 +02:00
Duck bdde2140b3 Core: Remove worlds.generic.PlandoItem (#6035) 2026-04-18 01:24:25 +02:00
qwint 42d24b4869 HK: add manifest (#6057) 2026-04-18 01:23:19 +02:00
black-sliver 14aa6571de WebHost: Fix: strict suuid converter (#6130)
* WebHost: disallow '=' and whitespace in SUUID and append the correct number of '='

This makes Room IDs unambiguous and avoids breaking if b64decode ever becomes strict.

* Tests, WebHost: add SUUID tests

* Tests, WebHost: add edge cases to SUUID tests
2026-04-13 20:17:27 +00:00
sudo-nano 37d32a10b1 Factorio: Improve English instructions (#5306) 2026-04-13 11:19:23 +02:00
Silvris 0ef2ed5b81 Docs: fix misinformation in tests.md (#6125) 2026-04-09 21:27:39 +02:00
PoryGone b7c4fcb4c6 DKC3: Delete DKC3 (#6097) 2026-04-08 21:35:43 +02:00
williamronn bcd3f9a74c WebHost: 2025 to 2026 (#6094) 2026-04-07 22:16:39 +02:00
Trevor L 6262235161 Bomb Rush Cyberfunk: Fix logic requirement increasing after collecting item (#6105) 2026-04-07 22:15:47 +02:00
Silvris 030cb4b578 Various Worlds: purge world: MultiWorld (#5973) 2026-04-07 22:13:31 +02:00
agilbert1412 36bab6f52a Stardew Valley: 0.6.7 Day 1 fixes (#6098)
- The Shane and Sebastian Portrait filler items werent tagged properly.
- The Beach Farm adds one secretsanity check, so it needs to be in the allsanity preset
- The Allsanity preset is renamed to "Maxsanity" to encourage better defined terminology and Allsanity means something else
- The "All Random" preset has been removed entirely. It has been the cause of too many beginner footguns over the years. People can still achieve the effect manually, but at least they'll have to try a little bit to ruin their own experience.
2026-04-07 21:38:04 +02:00
Duck e0cfef3407 APWorld Builder: Add option to skip opening output folder (#5847) 2026-04-05 19:50:17 +02:00
Duck bb2a775c05 WebHost: Fix hosting with invalid worlds installed (#5648) 2026-04-04 07:52:27 +02:00
palex00 427b147818 APQuest: Add Link to Poptracker Pack to Setup Guide (#6089)
* Update setup_en.md

* Update setup_de.md
2026-04-01 22:43:14 +02:00
NewSoupVi 3f3c343fb3 APQuest: Fix focus issues (#6090)
* My best attempt at fixing focus issues on Android

* didn't mean to remove that
2026-04-01 22:42:08 +02:00
Bryce Wilson debe4cf035 Pokemon Emerald: Bump version (#6083) 2026-04-01 19:18:42 +02:00
Silvris 68f25f4642 MM3: Bump world version (#6088) 2026-04-01 19:18:11 +02:00
NewSoupVi 3c4af8f432 APQuest: Tap to move (#6082)
* Tap to move

* inputs

* cleanup

* oops
2026-03-31 20:55:52 +02:00
NewSoupVi 5360b6bb37 The Witness: (Unbeatable seed) Ensure Desert Laser Redirection is required when the box is rotated (#5889)
* Unbeatable seed: 11 lasers + redirect when the box is rotated

* naming
2026-03-31 00:31:05 +02:00
black-sliver 2ee20a3ac4 CI: set permissions, update and pin actions, CodeQL for actions (#6073)
* CI: reduce default permissions to minimum

* CI: update pin actions

Most of them. CodeQL and action-gh-release is untouched for now.
Immutable actions and actions/* are pinned to version,
other actions are pinned to hash.

* CI: make use of archive: false in upload-artifact

also set compression level and error behavior for scan-build upload.

* CI: update codeql and enable scanning actions
2026-03-30 21:46:43 +02:00
Ian Robinson c640d2fa24 Rule Builder: Add field resolvers (#5919) 2026-03-30 18:19:10 +02:00
James White 58a6407040 SMW: Prevent receiving your own traps while aliased (#5763) 2026-03-30 17:27:10 +02:00
EdricY ba7ca0bd23 Options Creator: bind free text set_value to text instead of on_text_validate (#5915) 2026-03-30 17:25:25 +02:00
Louis M bdbf72f148 Aquaria: Fixing bug where Urchin Costume is not a progression damaging item (#5998) 2026-03-30 01:40:05 +02:00
Jarno 2b46df90b4 Satisfactory: Fixed buildings missing from goal check (#5772) 2026-03-30 00:46:01 +02:00
NewSoupVi 88dc135960 APQuest: Various fixes (#6079)
* Import Buffer from typing_extensions instead of collections.abc for 3.11 compat

* always re-set sound volumes before playing

* fix game window scaling if parent is vertical

* make default volume lower
2026-03-30 00:32:06 +02:00
Duck 95f696c04f WebHost: Remove space before comma separators in tutorial authors (#5999)
* Remove space before comma

* Factorio authors update

* Simplify template
2026-03-30 00:19:54 +02:00
el-u 96277fe9be lufia2ac: update CollectionRule import (#5936) 2026-03-29 23:37:53 +02:00
XxDERProjectxX a7a7879df4 Satisfactory: bug fix in __init__.py (#5930)
Solved indentation error to return to intended functionality
2026-03-29 23:34:21 +02:00
Alchav 773f3c4f08 Super Mario Land 2: Fix Space Zone 2 Logic (#6025) 2026-03-29 23:25:46 +02:00
agilbert1412 139856a573 Stardew Valley: Fixed an issue where some specific option combinations could create more items than locations (#6012)
* - Improved the dynamic locations count algorithm to take into account the nature of various heavy settings in both directions

* - Fixes from Code Review

* - We're only testing for sunday locations, might as well only take sunday locations in the list to test

* - One more slight optimization

* - Added consideration for bundles per room in filler locations counting

* - Registered some more IDs to handle items up to 10
2026-03-29 23:21:29 +02:00
Noa Aarts a1ed804267 Stardew Valley: trimmed lucky purple shorts need gold to make (#6034)
The current logic only requires the shorts and a sewing machine, but a
gold bar is also necessary
2026-03-29 23:20:24 +02:00
agilbert1412 2d58e7953c Stardew valley: Four small fixes (#6055)
* - Fixed the Dr Seuss Bundle asking for tigerseye (mineral) instead of tiger trout (fish)

* - Made blue grass starter more consistent

* - Fragments of the past does not rely on ginger island

* - Removed legacy hard coded strange bun recipe that messed with chefsanity logic
2026-03-29 23:20:00 +02:00
Flit 393ed51203 Messenger: Require Wingsuit to traverse Dark Cave (#6059) 2026-03-29 23:16:34 +02:00
Mysteryem 03c9d0717b Muse Dash: Fix nondeterministic generation with include_songs (#6040)
The include_songs option is an OptionSet, whose value is a set, but was being iterated to produce self.included_songs. Sets are unordered and may have a different iteration order each time a python process is run. This meant that the order of the elements in self.included_songs could differ even when generating with a fixed seed.

This caused nondeterministic generation with the same seed because create_song_pool() deterministically randomly picks songs from self.included_songs, which could be in a different order each time, so different songs could be picked.
2026-03-29 23:12:25 +02:00
Bryce Wilson 5ca50cd8d3 Pokemon Emerald: Fix Latios KeyError (#6056) 2026-03-29 23:10:16 +02:00
Sebastian 36cf86f2e8 Docs: update macOS setup instructions for more specificity on Python version (#6078) 2026-03-29 21:18:03 +02:00
Duck 1705620c4f Launcher: Add konsole to terminal list and rework launch dialog (#5684)
* Make component launching indicate if no terminal window, add konsole

* Attempt to spell better and remove whitespace

* Update terminal priority

* Make helper for clearing LD_LIBRARY_PATH

* Add handling to linux launch

* Hopefully fix setter

* Apply suggestions from code review

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-29 20:07:55 +02:00
black-sliver ffe4c6dd15 Core, Webhost: update and pin dependency versions (#6075) 2026-03-29 19:44:29 +02:00
black-sliver cf47cc67c0 Clients: remove datapackage from persistent_storage ... (#6074)
... next time it gets written to.
This makes loading peristent_storage faster in the future.
2026-03-29 19:43:26 +02:00
Ian Robinson 645f25a94e setup.py: add rule_builder.cached_world to included list (#6070) 2026-03-29 18:29:37 +02:00
qwint 74f41e3733 Core: Make Generate.main only init logging on __main__ (#6069) 2026-03-28 00:58:36 +01:00
Phaneros 4276c6d6b0 sc2: Fixing random fill errors in unit tests (#6045) 2026-03-27 22:45:38 +01:00
Justus Lind 116ab2286a Muse Dash: Add support for Wuthering Waves Pioneer Podcast and Ay-Aye Horse (#6071) 2026-03-27 18:36:36 +01:00
Ian Robinson fb45a2f87e Rule Builder: Fix count resolution when Oring HasAnyCount (#6048) 2026-03-18 18:54:17 +01:00
Fabian Dill 2e5356ad05 Core: other resources guide (#6043)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>
2026-03-18 03:30:22 +01:00
lepideble 8457ff3e4b Factorio: only show fluid boxes on assembling machine 1 when the selected recipe needs fluids (#5412) 2026-03-16 15:17:54 +01:00
Fabian Dill 70fc3e05fb Webhost: port reuse fix & configurable max room timeout (#6033)
* WebHost: make autolauncher max room timeout configurable

* WebHost: launch rooms with assigned port first
2026-03-12 02:48:45 +01:00
Duck d01c9577ab CommonClient: Add explicit message for connection timeout (#5842)
* Change timeout and add timeout-specific message

* Revert open_timeout
2026-03-11 23:46:59 +01:00
qwint 260bae359d Core: Update .gitignore to include an exe setup.py downloads (#6031) 2026-03-11 21:37:00 +01:00
Mysteryem 3016379b85 KH2: Fix nondeterministic generation when CasualBounties is enabled (#5967)
When CasualBounties was enabled, the location names in
`exclusion_table["HitlistCasual"]` would be iterated into
`self.random_super_boss_list` in `generate_early`, but
`exclusion_table["HitlistCasual"]` was a `set`, so its iteration order
would vary on each generation, even with same seed.

Random location names would be picked from `self.random_super_boss_list`
to place Bounty items at, so different locations could be picked on each
generation with the same seed.

`exclusion_table["Hitlist"]` is similar and was already a `list`,
avoiding the issue of nondeterministic iteration order, so
`exclusion_table["HitlistCasual"]` has been changed to a `list` to
match.
2026-03-10 23:06:44 +01:00
Fabian Dill 775f56036c fix type 2025-01-09 17:02:36 +01:00
Fabian Dill 39342ad5d5 review concerns 2025-01-09 17:01:19 +01:00
Fabian Dill ce09144261 Core: add creation reason to filler 2025-01-08 00:30:03 +01:00
214 changed files with 4283 additions and 6375 deletions
+2
View File
@@ -3,6 +3,7 @@
"../BizHawkClient.py",
"../Patch.py",
"../rule_builder/cached_world.py",
"../rule_builder/field_resolvers.py",
"../rule_builder/options.py",
"../rule_builder/rules.py",
"../test/param.py",
@@ -18,6 +19,7 @@
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../test/webhost/test_suuid.py",
"../worlds/AutoSNIClient.py",
"type_check.py"
],
+4 -2
View File
@@ -14,6 +14,8 @@ env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
permissions: {}
jobs:
flake8-or-mypy:
strategy:
@@ -25,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
@@ -50,7 +52,7 @@ jobs:
run: |
echo "diff=." >> $GITHUB_ENV
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6.2.0
if: env.diff != ''
with:
python-version: '3.11'
+15 -16
View File
@@ -41,9 +41,9 @@ jobs:
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -82,7 +82,7 @@ jobs:
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
@@ -110,18 +110,17 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
archive: false
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
archive: false
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
@@ -129,14 +128,14 @@ jobs:
runs-on: ubuntu-22.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -173,7 +172,7 @@ jobs:
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
@@ -204,17 +203,17 @@ jobs:
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
archive: false
# TODO: decide if we want to also upload the zsync
if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
archive: false
if-no-files-found: error
retention-days: 7
+16 -8
View File
@@ -17,17 +17,26 @@ on:
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '**/action.yml'
- '**/action.yaml'
schedule:
- cron: '44 8 * * 1'
permissions:
security-events: write
jobs:
analyze:
name: Analyze
@@ -36,18 +45,17 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
language: [ 'javascript', 'python', 'actions' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4.35.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4.35.1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -72,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4.35.1
+3 -1
View File
@@ -24,6 +24,8 @@ on:
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
permissions: {}
jobs:
ctest:
runs-on: ${{ matrix.os }}
@@ -35,7 +37,7 @@ jobs:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
+9 -7
View File
@@ -19,6 +19,8 @@ on:
env:
REGISTRY: ghcr.io
permissions: {}
jobs:
prepare:
runs-on: ubuntu-latest
@@ -29,7 +31,7 @@ jobs:
package-name: ${{ steps.package.outputs.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
- name: Set lowercase image name
id: image
@@ -43,7 +45,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
tags: |
@@ -92,13 +94,13 @@ jobs:
cache-scope: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -115,7 +117,7 @@ jobs:
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7.0.0
with:
context: .
file: ./Dockerfile
@@ -135,7 +137,7 @@ jobs:
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: 'Apply content-based labels'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6.0.1
with:
sync-labels: false
peer_review:
+6 -6
View File
@@ -48,9 +48,9 @@ jobs:
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -88,7 +88,7 @@ jobs:
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
@@ -114,14 +114,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '~3.12.7'
check-latest: true
@@ -157,7 +157,7 @@ jobs:
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4.1.0
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
+7 -3
View File
@@ -28,12 +28,14 @@ on:
- 'requirements.txt'
- '.github/workflows/scan-build.yml'
permissions: {}
jobs:
scan-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
with:
submodules: recursive
- name: Install newer Clang
@@ -45,7 +47,7 @@ jobs:
run: |
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: '3.11'
- name: Install dependencies
@@ -59,7 +61,9 @@ jobs:
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7.0.0
with:
name: scan-build-reports
path: scan-build-reports
compression-level: 9 # highly compressible
if-no-files-found: error
+4 -2
View File
@@ -14,13 +14,15 @@ on:
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
permissions: {}
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.11"
+6 -4
View File
@@ -29,6 +29,8 @@ on:
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
permissions: {}
jobs:
unit:
runs-on: ${{ matrix.os }}
@@ -51,9 +53,9 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
@@ -78,9 +80,9 @@ jobs:
- {version: '3.13'} # current
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
+1
View File
@@ -45,6 +45,7 @@ EnemizerCLI/
/SNI/
/sni-*/
/appimagetool*
/VC_redist.x64.exe
/host.yaml
/options.yaml
/config.yaml
+2 -1
View File
@@ -374,7 +374,8 @@ class MultiWorld():
items_to_add.append(AutoWorld.call_single(self, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player,
AutoWorld.FillerReason.item_link))
self.random.shuffle(items_to_add)
self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
+3 -1
View File
@@ -773,7 +773,7 @@ class CommonContext:
if len(parts) == 1:
parts = title.split(', ', 1)
if len(parts) > 1:
text = parts[1] + '\n\n' + text
text = f"{parts[1]}\n\n{text}" if text else parts[1]
title = parts[0]
# display error
self._messagebox = MessageBox(title, text, error=True)
@@ -896,6 +896,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
"May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except asyncio.TimeoutError:
ctx.handle_connection_loss("Failed to connect to the multiworld server. Connection timed out.")
except OSError:
ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception:
+5 -5
View File
@@ -7,7 +7,7 @@ from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from Options import Accessibility
from worlds.AutoWorld import call_all
from worlds.AutoWorld import call_all, FillerReason
from worlds.generic.Rules import add_item_rule
@@ -340,7 +340,7 @@ def remaining_fill(multiworld: MultiWorld,
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
last_batch.append(multiworld.worlds[item.player].create_filler())
last_batch.append(multiworld.worlds[item.player].create_filler(FillerReason.panic_fill))
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
@@ -599,7 +599,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
filleritempool.append(multiworld.worlds[item.player].create_filler(FillerReason.panic_fill))
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
@@ -623,7 +623,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
move_unplaceable_to_start_inventory=panic_method == "start_inventory")
if excludedlocations:
raise FillError(
@@ -635,7 +635,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool,
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
move_unplaceable_to_start_inventory=panic_method == "start_inventory")
unplaced = restitempool
unfilled = defaultlocations
+2 -1
View File
@@ -87,7 +87,8 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
if __name__ == "__main__":
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed)
seed_name = get_seed_name(random)
+29 -20
View File
@@ -29,8 +29,8 @@ if __name__ == "__main__":
import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path,
messagebox, open_filename, user_path)
if __name__ == "__main__":
init_logging('Launcher')
@@ -52,10 +52,7 @@ def open_host_yaml():
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
subprocess.Popen([exe, file], env=env)
def open_patch():
@@ -106,10 +103,7 @@ def open_folder(folder_path):
return
if exe:
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
@@ -202,22 +196,32 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe, in_terminal=False):
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
"""Runs the given command/args in `exe` in a new process.
If `in_terminal` is True, it will attempt to run in a terminal window,
and the return value will indicate whether one was found."""
if in_terminal:
if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return
return True
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm")
if terminal:
subprocess.Popen([terminal, '-e', shlex.join(exe)])
return
# Clear LD_LIB_PATH during terminal startup, but set it again when running command in case it's needed
ld_lib_path = os.environ.get("LD_LIBRARY_PATH")
lib_path_setter = f"env LD_LIBRARY_PATH={shlex.quote(ld_lib_path)} " if ld_lib_path else ""
env = env_cleared_lib_path()
subprocess.Popen([terminal, "-e", lib_path_setter + shlex.join(exe)], env=env)
return True
elif is_macos:
terminal = [which('open'), '-W', '-a', 'Terminal.app']
terminal = [which("open"), "-W", "-a", "Terminal.app"]
subprocess.Popen([*terminal, *exe])
return
return True
subprocess.Popen(exe)
return False
def create_shortcut(button: Any, component: Component) -> None:
@@ -406,12 +410,17 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
@staticmethod
def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
open_text = "Opening in a new window..."
if button.component.func:
# Note: if we want to draw the Snackbar before running func, func needs to be wrapped in schedule_once
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
# if launch returns False, it started the process in background (not in a new terminal)
if not launch(get_exe(button.component), button.component.cli) and button.component.cli:
open_text = "Running in the background..."
MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
+2 -1
View File
@@ -174,7 +174,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
needed_items = target_per_player[player] - sum(unfound_items.values())
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
new_itempool += [multiworld.worlds[player].create_filler(AutoWorld.FillerReason.start_inventory_from_pool)
for _ in range(needed_items)]
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
multiworld.itempool[:] = new_itempool
+89 -94
View File
@@ -44,9 +44,8 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore, MultiData, Hint, HintStatus, GamesPackage
SlotType, LocationStore, MultiData, Hint, HintStatus
from BaseClasses import ItemClassification
from apmw.multiserver.gamespackagecache import GamesPackageCache
min_client_version = Version(0, 5, 0)
@@ -242,38 +241,21 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
played_games: set[str]
item_names: typing.Dict[str, typing.Dict[int, str]]
item_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[str, typing.Dict[int, str]]
location_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
games_package_cache: GamesPackageCache
logger: logging.Logger
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",
countdown_mode: str = "auto",
remaining_mode: str = "disabled",
auto_shutdown: typing.SupportsFloat = 0,
compatibility: int = 2,
log_network: bool = False,
games_package_cache: GamesPackageCache | None = None,
logger: logging.Logger = logging.getLogger(),
) -> None:
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",
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 = {}
@@ -324,7 +306,6 @@ class Context:
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
self.played_games = set()
self.minimum_client_versions: typing.Dict[int, Version] = {}
self.seed_name = ""
self.groups = {}
@@ -334,10 +315,9 @@ class Context:
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
self.spheres = []
self.games_package_cache = games_package_cache or GamesPackageCache()
# init empty to satisfy linter, I suppose
self.reduced_games_package = {}
self.gamespackage = {}
self.checksums = {}
self.item_name_groups = {}
self.location_name_groups = {}
@@ -349,11 +329,50 @@ class Context:
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
# Data package retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
self.location_name_groups = {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[game_name][item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[game_name][location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
archipelago_item_names = self.item_names["Archipelago"]
archipelago_location_names = self.location_names["Archipelago"]
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
# Add Archipelago items and locations to each data package.
self.item_names[game].update(archipelago_item_names)
self.location_names[game].update(archipelago_location_names)
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.reduced_games_package[game]["item_name_to_id"] if game in self.reduced_games_package else None
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.reduced_games_package[game]["location_name_to_id"] if game in self.reduced_games_package else None
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
@@ -463,17 +482,19 @@ class Context:
with open(multidatapath, 'rb') as f:
data = f.read()
self._load(self.decompress(data), use_embedded_server_options)
self._load(self.decompress(data), {}, use_embedded_server_options)
self.data_filename = multidatapath
@staticmethod
def decompress(data: bytes) -> typing.Any:
def decompress(data: bytes) -> dict:
format_version = data[0]
if format_version > 3:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: MultiData, use_embedded_server_options: bool) -> None:
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
# there might be a better place to put this.
race_mode = decoded_obj.get("race_mode", 0)
@@ -494,7 +515,6 @@ class Context:
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.played_games = {"Archipelago"} | {self.games[x] for x in range(1, len(self.games) + 1)}
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
@@ -539,11 +559,18 @@ class Context:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
# load and apply world data and (embedded) data package
self._load_world_data()
self._load_data_package(decoded_obj.get("datapackage", {}))
# embedded data package
for game_name, data in decoded_obj.get("datapackage", {}).items():
if game_name in game_data_packages:
data = game_data_packages[game_name]
self.logger.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
if "location_name_groups" in data:
self.location_name_groups[game_name] = data["location_name_groups"]
del data["location_name_groups"]
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
self._init_game_data()
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
for game_name, data in self.location_name_groups.items():
@@ -552,55 +579,6 @@ class Context:
# sorted access spheres
self.spheres = decoded_obj.get("spheres", [])
def _load_world_data(self) -> None:
import worlds
for world_name, world in worlds.AutoWorldRegister.world_types.items():
# TODO: move hint_blacklist into GamesPackage?
self.non_hintable_names[world_name] = world.hint_blacklist
def _load_data_package(self, data_package: dict[str, GamesPackage]) -> None:
"""Populates reduced_games_package, item_name_groups, location_name_groups from static data and data_package"""
# NOTE: for worlds loaded from db, only checksum is set in GamesPackage, but this is handled by cache
for game_name in sorted(self.played_games):
if game_name in data_package:
self.logger.info(f"Loading embedded data package for game {game_name}")
data = self.games_package_cache.get(game_name, data_package[game_name])
else:
# NOTE: we still allow uploading a game without datapackage. Once that is changed, we could drop this.
data = self.games_package_cache.get_static(game_name)
(
self.reduced_games_package[game_name],
self.item_name_groups[game_name],
self.location_name_groups[game_name],
) = data
del self.games_package_cache # Not used past this point. Free memory.
def _init_game_data(self) -> None:
"""Update internal values from previously loaded data packages"""
for game_name, game_package in self.reduced_games_package.items():
if game_name not in self.played_games:
continue
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
# NOTE: we could save more memory by moving the stuff below to data package cache as well
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[game_name][item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[game_name][location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
archipelago_item_names = self.item_names["Archipelago"]
archipelago_location_names = self.location_names["Archipelago"]
for game in [game_name for game_name in self.reduced_games_package if game_name != "Archipelago"]:
# Add Archipelago items and locations to each data package.
self.item_names[game].update(archipelago_item_names)
self.location_names[game].update(archipelago_location_names)
# saving
def save(self, now=False) -> bool:
@@ -941,10 +919,12 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
async def on_client_connected(ctx: Context, client: Client):
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'games': sorted(ctx.played_games),
'games': games,
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
@@ -953,7 +933,8 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_checksums': ctx.checksums,
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -1959,11 +1940,25 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
games = {
name: game_data for name, game_data in ctx.reduced_games_package.items()
if name in set(args.get("games", []))
}
await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": {"games": games}}])
exclusions = args.get("exclusions", [])
if "games" in args:
games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0
elif exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name not in exclusions}
package = {"games": games}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": ctx.gamespackage}}])
elif client.auth:
if cmd == "ConnectUpdate":
+31 -8
View File
@@ -212,6 +212,13 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
else:
return cls.name_lookup[value]
def __eq__(self, other: typing.Any) -> bool:
if isinstance(other, self.__class__):
return self.value == other.value
if isinstance(other, Option):
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
return self.value == other
def __int__(self) -> T:
return self.value
@@ -930,13 +937,34 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
cull_zeroes: bool = False
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
cleaned_dict = {}
invalid_value_errors = []
for key, value in value.items():
if not isinstance(value, (int, float)) or int(value) != value:
invalid_value_errors += [f"Invalid value {value} for key {key}, must be an integer."]
continue
if self.cull_zeroes and value == 0:
continue
cleaned_dict[key] = int(value)
if invalid_value_errors:
type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors
raise TypeError("\n".join(invalid_value_errors))
super(OptionCounter, self).__init__(collections.Counter(cleaned_dict))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
self.verify_values()
def verify_values(self):
range_errors = []
if self.max is not None:
@@ -959,13 +987,8 @@ class OptionCounter(OptionDict):
class ItemDict(OptionCounter):
verify_item_name = True
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
super(ItemDict, self).__init__(value)
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
cull_zeroes = True
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
+4 -3
View File
@@ -384,10 +384,11 @@ class OptionsCreator(ThemedApp):
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
def set_value(instance, value):
self.options[name] = value
text.bind(on_text_validate=set_value)
text.bind(text=set_value)
self.options[name] = option.default
return text
def create_choice(self, option: typing.Type[Choice], name: str):
-1
View File
@@ -24,7 +24,6 @@ Currently, the following games are supported:
* The Witness
* Sonic Adventure 2: Battle
* Starcraft 2
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
+19 -21
View File
@@ -22,7 +22,7 @@ from datetime import datetime, timezone
from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
@@ -236,10 +236,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
subprocess.call([open_command, filename], env=env)
@@ -345,6 +342,9 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
try:
with open(path, "r") as f:
storage = unsafe_parse_yaml(f.read())
if "datapackage" in storage:
del storage["datapackage"]
logging.debug("Removed old datapackage from persistent storage")
except Exception as e:
logging.debug(f"Could not read store: {e}")
if storage is None:
@@ -369,11 +369,6 @@ def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) ->
except Exception as e:
logging.debug(f"Could not load data package: {e}")
# fall back to old cache
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
if cache.get("checksum") == checksum:
return cache
# cache does not match
return {}
@@ -455,13 +450,10 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
@@ -475,10 +467,6 @@ class RestrictedUnpickler(pickle.Unpickler):
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem":
if not self.generic_properties_module:
self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module == "Options":
@@ -758,6 +746,19 @@ def is_kivy_running() -> bool:
return False
def env_cleared_lib_path() -> Mapping[str, str]:
"""
Creates a copy of the current environment vars with the LD_LIBRARY_PATH removed if set, as this can interfere when
launching something in a subprocess.
"""
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"]
return env
def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
if is_kivy_running():
raise RuntimeError("kivy should not be running in multiprocess")
@@ -770,10 +771,7 @@ def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(save_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
env = env_cleared_lib_path()
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
+2 -1
View File
@@ -110,13 +110,14 @@ if __name__ == "__main__":
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
from worlds import AutoWorldRegister
from worlds import AutoWorldRegister, network_data_package
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
network_data_package["games"] = {k: v for k, v in network_data_package["games"].items() if k not in invalid_worlds}
create_options_files()
copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]:
+5 -2
View File
@@ -42,11 +42,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["GAME_PORTS"] = ["49152-65535", 0]
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# maximum time in seconds since last activity for a room to be hosted
app.config["MAX_ROOM_TIMEOUT"] = 259200
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
@@ -70,7 +71,9 @@ CLI(app)
def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
if "=" in value or any(c.isspace() for c in value):
raise ValueError("Invalid UUID format")
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)))
def to_url(value: uuid.UUID) -> str:
+4 -4
View File
@@ -9,7 +9,7 @@ from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit, PrimaryKey
from pony.orm import db_session, select, commit, PrimaryKey, desc
from Utils import restricted_loads, utcnow
from .locker import Locker, AlreadyRunningException
@@ -129,7 +129,8 @@ def autohost(config: dict):
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= utcnow() - timedelta(days=3))
room.last_activity >= utcnow() - timedelta(
seconds=config["MAX_ROOM_TIMEOUT"])).order_by(desc(Room.last_port))
for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5):
@@ -187,7 +188,6 @@ class MultiworldInstance():
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
self.game_ports = config["GAME_PORTS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
@@ -198,7 +198,7 @@ class MultiworldInstance():
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host, self.game_ports,
self.cert, self.key, self.host,
self.rooms_to_start, self.rooms_shutting_down),
name=self.name)
process.start()
+88 -178
View File
@@ -4,7 +4,6 @@ import asyncio
import collections
import datetime
import functools
import itertools
import logging
import multiprocessing
import pickle
@@ -14,9 +13,7 @@ import threading
import time
import typing
import sys
from asyncio import AbstractEventLoop
import psutil
import websockets
from pony.orm import commit, db_session, select
@@ -27,10 +24,8 @@ from MultiServer import (
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless
from NetUtils import GamesPackage
from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
from .locker import Locker
from .models import Command, Room, db
from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -67,39 +62,18 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context):
room_id: int
video: dict[tuple[int, int], tuple[str, str]]
main_loop: AbstractEventLoop
static_server_data: StaticServerData
def __init__(
self,
static_server_data: StaticServerData,
games_package_cache: DBGamesPackageCache,
logger: logging.Logger,
) -> None:
def __init__(self, static_server_data: dict, logger: logging.Logger):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
super(WebHostContext, self).__init__(
"",
0,
"",
"",
1,
40,
True,
"enabled",
"enabled",
"enabled",
0,
2,
games_package_cache=games_package_cache,
logger=logger,
)
self.tags = ["AP", "WebHost"]
self.video = {}
self.main_loop = asyncio.get_running_loop()
self.static_server_data = static_server_data
self.games_package_cache = games_package_cache
super(WebHostContext, self).__init__("", 0, "", "", 1,
40, True, "enabled", "enabled",
"enabled", 0, 2, logger=logger)
del self.static_server_data
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["AP", "WebHost"]
def __del__(self):
try:
@@ -109,6 +83,12 @@ class WebHostContext(Context):
except ImportError:
self.logger.debug("Context destroyed")
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
async def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
@@ -135,17 +115,45 @@ class WebHostContext(Context):
if room.last_port:
self.port = room.last_port
else:
self.port = 0
self.port = get_random_port()
multidata = self.decompress(room.seed.multidata)
return self._load(multidata, True)
game_data_packages = {}
def _load_world_data(self):
# Use static_server_data, but skip static data package since that is in cache anyway.
# Also NOT importing worlds here!
# FIXME: does this copy the non_hintable_names (also for games not part of the room)?
self.non_hintable_names = collections.defaultdict(frozenset, self.static_server_data["non_hintable_names"])
del self.static_server_data # Not used past this point. Free memory.
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages and not missing_checksum:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
def init_save(self, enabled: bool = True):
self.saving = enabled
@@ -173,117 +181,38 @@ class WebHostContext(Context):
return d
class GameRangePorts(typing.NamedTuple):
parsed_ports: list[range]
weights: list[int]
ephemeral_allowed: bool
@functools.cache
def parse_game_ports(game_ports: tuple[str | int, ...]) -> GameRangePorts:
parsed_ports: list[range] = []
weights: list[int] = []
ephemeral_allowed = False
total_length = 0
for item in game_ports:
if isinstance(item, str) and "-" in item:
start, end = map(int, item.split("-"))
x = range(start, end + 1)
total_length += len(x)
weights.append(total_length)
parsed_ports.append(x)
elif int(item) == 0:
ephemeral_allowed = True
else:
total_length += 1
weights.append(total_length)
num = int(item)
parsed_ports.append(range(num, num + 1))
return GameRangePorts(parsed_ports, weights, ephemeral_allowed)
def weighted_random(ranges: list[range], cum_weights: list[int]) -> int:
[picked] = random.choices(ranges, cum_weights=cum_weights)
return random.randrange(picked.start, picked.stop, picked.step)
def create_random_port_socket(game_ports: tuple[str | int, ...], host: str) -> socket.socket:
parsed_ports, weights, ephemeral_allowed = parse_game_ports(game_ports)
used_ports = get_used_ports()
i = 1024 if len(parsed_ports) > 0 else 0
while i > 0:
port_num = weighted_random(parsed_ports, weights)
if port_num in used_ports:
used_ports = get_used_ports()
continue
i -= 0
try:
return socket.create_server((host, port_num))
except OSError:
pass
if ephemeral_allowed:
return socket.create_server((host, 0))
raise OSError(98, "No available ports")
def try_conns_per_process(p: psutil.Process) -> typing.Iterable[int]:
try:
return (c.laddr.port for c in p.net_connections("tcp4"))
except psutil.AccessDenied:
return ()
def get_active_net_connections() -> typing.Iterable[int]:
# Don't even try to check if system using AIX
if psutil.AIX:
return ()
try:
return (c.laddr.port for c in psutil.net_connections("tcp4"))
# raises AccessDenied when done on macOS
except psutil.AccessDenied:
# flatten the list of iterables
return itertools.chain.from_iterable(map(
# get the net connections of the process and then map its ports
try_conns_per_process,
# this method has caching handled by psutil
psutil.process_iter(["net_connections"])
))
def get_used_ports():
last_used_ports: tuple[frozenset[int], float] | None = getattr(get_used_ports, "last", None)
t_hash = round(time.time() / 90) # cache for 90 seconds
if last_used_ports is None or last_used_ports[1] != t_hash:
last_used_ports = (frozenset(get_active_net_connections()), t_hash)
setattr(get_used_ports, "last", last_used_ports)
return last_used_ports[0]
class StaticServerData(typing.TypedDict, total=True):
non_hintable_names: dict[str, typing.AbstractSet[str]]
games_package: dict[str, GamesPackage]
def get_random_port():
return random.randint(49152, 65535)
@cache_argsless
def get_static_server_data() -> StaticServerData:
def get_static_server_data() -> dict:
import worlds
return {
data = {
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"games_package": worlds.network_data_package["games"]
"gamespackage": {
world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
}
return data
def set_up_logging(room_id) -> logging.Logger:
import os
@@ -316,19 +245,9 @@ def tear_down_logging(room_id):
del logging.Logger.manager.loggerDict[logger_name]
def run_server_process(
name: str,
ponyconfig: dict[str, typing.Any],
static_server_data: StaticServerData,
cert_file: typing.Optional[str],
cert_key_file: typing.Optional[str],
host: str,
game_ports: typing.Iterable[str | int],
rooms_to_run: multiprocessing.Queue,
rooms_shutting_down: multiprocessing.Queue,
) -> None:
import gc
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
@@ -344,11 +263,6 @@ def run_server_process(
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
# prime the data package cache with static data
games_package_cache = DBGamesPackageCache(static_server_data["games_package"])
# convert to tuple because its hashable
game_ports = tuple(game_ports)
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -356,6 +270,8 @@ def run_server_process(
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
if not cert_file:
def get_ssl_context():
return None
@@ -380,30 +296,24 @@ def run_server_process(
with Locker(f"RoomLocker {room_id}"):
try:
logger = set_up_logging(room_id)
ctx = WebHostContext(static_server_data, games_package_cache, logger)
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
assert ctx.server is None
if ctx.port != 0:
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
except OSError:
ctx.port = 0
if ctx.port == 0:
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
sock=create_random_port_socket(game_ports, ctx.host),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
@@ -478,7 +388,7 @@ def run_server_process(
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect()
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
+12 -12
View File
@@ -1,14 +1,14 @@
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19; python_version <= '3.12'
flask==3.1.3
werkzeug==3.1.6
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
waitress==3.0.2
Flask-Caching==2.3.1
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
Flask-Limiter>=3.12
Flask-Cors>=6.0.2
bokeh>=3.6.3
markupsafe>=3.0.2
setproctitle>=1.3.5
mistune>=3.1.3
docutils>=0.22.2
Flask-Limiter==4.1.1
Flask-Cors==6.0.2
bokeh==3.8.2
markupsafe==3.0.3
setproctitle==1.3.7
mistune==3.2.0
docutils==0.22.4
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

-11
View File
@@ -33,17 +33,6 @@ html{
z-index: 10;
}
#landing-header h5 {
color: #ffffff;
font-style: italic;
font-size: 28px;
margin-top: 15px;
margin-bottom: -43px;
text-shadow: 1px 1px 7px #000000;
font-kerning: none;
z-index: 10;
}
#landing-links{
margin-left: auto;
margin-right: auto;
+1 -1
View File
@@ -1,6 +1,6 @@
{% block footer %}
<footer id="island-footer">
<div id="copyright-notice">Copyright 2025 Archipelago</div>
<div id="copyright-notice">Copyright 2026 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-
+2 -3
View File
@@ -11,7 +11,7 @@
<div id="landing-wrapper">
<div id="landing-header">
<img id="landing-logo" src="static/static/branding/landing-logo.png" alt="Archipelago Logo" />
<h4>multiworld multi-game randomizer</h4><h5>beta</h5>
<h4>multiworld multi-game randomizer</h4>
</div>
<div id="landing-links">
<a href="/games" id="far-left-button">Supported<br />Games</a>
@@ -35,8 +35,7 @@
</div>
<div id="landing" class="grass-island">
<div id="landing-body">
<p id="first-line">Welcome to Archipelago Beta!</p>
<p>For the stable version, visit <a href="//archipelago.gg">Archipelago.gg</a>!</p>
<p id="first-line">Welcome to Archipelago!</p>
<p>
This is a cross-game modification system which randomizes different games, then uses the result to
build a single unified multi-player game. Items from one game may be present in another, and
-1
View File
@@ -21,7 +21,6 @@
</div>
{% endif %}
{% endwith %}
<div class="user-message">This is the beta site! For the stable version, visit <a href="https://archipelago.gg">Archipelago.gg</a>!</div>
{% block body %}
{% endblock %}
+3 -1
View File
@@ -33,7 +33,9 @@
<h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
custom worlds</a> section of the setup guide and the
<a href="{{ url_for("tutorial", game="Archipelago", file="other_en") }}">other games and tools guide</a>
to find more.</p>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div class="page-controls">
+1 -5
View File
@@ -20,11 +20,7 @@
{% for file_name, file_data in tutorial_data.files.items() %}
<li>
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
by
{% for author in file_data.authors %}
{{ author }}
{% if not loop.last %}, {% endif %}
{% endfor %}
by {{ file_data.authors | join(", ") }}
</li>
{% endfor %}
</ul>
View File
View File
-96
View File
@@ -1,96 +0,0 @@
import typing as t
from weakref import WeakValueDictionary
from NetUtils import GamesPackage
GameAndChecksum = tuple[str, str | None]
ItemNameGroups = dict[str, list[str]]
LocationNameGroups = dict[str, list[str]]
K = t.TypeVar("K")
V = t.TypeVar("V")
class DictLike(dict[K, V]):
__slots__ = ("__weakref__",)
class GamesPackageCache:
# NOTE: this uses 3 separate collections because unpacking the get() result would end the container lifetime
_reduced_games_packages: WeakValueDictionary[GameAndChecksum, GamesPackage]
"""Does not include item_name_groups nor location_name_groups"""
_item_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
_location_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
def __init__(self) -> None:
self._reduced_games_packages = WeakValueDictionary()
self._item_name_groups = WeakValueDictionary()
self._location_name_groups = WeakValueDictionary()
def _get(
self,
cache_key: GameAndChecksum,
) -> tuple[GamesPackage | None, ItemNameGroups | None, LocationNameGroups | None]:
if cache_key[1] is None:
return None, None, None
return (
self._reduced_games_packages.get(cache_key, None),
self._item_name_groups.get(cache_key, None),
self._location_name_groups.get(cache_key, None),
)
def get(
self,
game: str,
full_games_package: GamesPackage,
) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
"""Loads and caches embedded data package provided by multidata"""
cache_key = (game, full_games_package.get("checksum", None))
cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups = self._get(cache_key)
if cached_reduced_games_package is None:
cached_reduced_games_package = t.cast(
t.Any,
DictLike(
{
"item_name_to_id": full_games_package["item_name_to_id"],
"location_name_to_id": full_games_package["location_name_to_id"],
"checksum": full_games_package.get("checksum", None),
}
),
)
if cache_key[1] is not None: # only cache if checksum is available
self._reduced_games_packages[cache_key] = cached_reduced_games_package
if cached_item_name_groups is None:
# optimize strings to be references instead of copies
item_names = {name: name for name in cached_reduced_games_package["item_name_to_id"].keys()}
cached_item_name_groups = DictLike(
{
group_name: [item_names.get(item_name, item_name) for item_name in group_items]
for group_name, group_items in full_games_package["item_name_groups"].items()
}
)
if cache_key[1] is not None: # only cache if checksum is available
self._item_name_groups[cache_key] = cached_item_name_groups
if cached_location_name_groups is None:
# optimize strings to be references instead of copies
location_names = {name: name for name in cached_reduced_games_package["location_name_to_id"].keys()}
cached_location_name_groups = DictLike(
{
group_name: [location_names.get(location_name, location_name) for location_name in group_locations]
for group_name, group_locations in full_games_package.get("location_name_groups", {}).items()
}
)
if cache_key[1] is not None: # only cache if checksum is available
self._location_name_groups[cache_key] = cached_location_name_groups
return cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups
def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
"""Loads legacy data package from installed worlds"""
import worlds
return self.get(game, worlds.network_data_package["games"][game])
View File
@@ -1,42 +0,0 @@
from typing_extensions import override
from NetUtils import GamesPackage
from Utils import restricted_loads
from apmw.multiserver.gamespackagecache import GamesPackageCache, ItemNameGroups, LocationNameGroups
class DBGamesPackageCache(GamesPackageCache):
_static: dict[str, tuple[GamesPackage, ItemNameGroups, LocationNameGroups]]
def __init__(self, static_games_package: dict[str, GamesPackage]) -> None:
super().__init__()
self._static = {
game: GamesPackageCache.get(self, game, games_package)
for game, games_package in static_games_package.items()
}
@override
def get(
self,
game: str,
full_games_package: GamesPackage,
) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
# for games started on webhost, full_games_package is likely unpopulated and only has the checksum field
cache_key = (game, full_games_package.get("checksum", None))
cached = self._get(cache_key)
if any(value is None for value in cached):
if "checksum" not in full_games_package:
return super().get(game, full_games_package) # no checksum, assume fully populated
from WebHostLib.models import GameDataPackage
row: GameDataPackage | None = GameDataPackage.get(checksum=full_games_package["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8 ...
return super().get(game, restricted_loads(row.data))
return super().get(game, full_games_package) # ... in which case full_games_package should be populated
return cached # type: ignore # mypy doesn't understand any value is None
@override
def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
return self._static[game]
-5
View File
@@ -19,8 +19,6 @@
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
/worlds/apquest/ @NewSoupVi
# Sudoku (APSudoku)
/worlds/apsudoku/ @EmilyV99
# Aquaria
/worlds/aquaria/ @tioui
@@ -58,9 +56,6 @@
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
# Donkey Kong Country 3
/worlds/dkc3/ @PoryGone
# DLCQuest
/worlds/dlcquest/ @axe-y @agilbert1412
-6
View File
@@ -69,12 +69,6 @@ flowchart LR
end
SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Super Mario World
subgraph Super Mario World
SMW[SNES]
+36
View File
@@ -129,6 +129,42 @@ common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
### Field resolvers
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a `FieldResolver` to define how to populate that field when the rule is being resolved.
There are two build-in field resolvers:
- `FromOption`: Resolves to the value of the given option
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get a nested attribute or dict item
```python
world.options.mcguffin_count = 5
world.precalculated_value = 99
rule = (
Has("A", count=FromOption(McguffinCount))
| HasGroup("Important items", count=FromWorldAttr("precalculated_value"))
)
# Results in Has("A", count=5) | HasGroup("Important items", count=99)
```
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and implements a `resolve` function:
```python
@dataclasses.dataclass(frozen=True)
class FromCustomResolution(FieldResolver, game="MyGame"):
modifier: str
@override
def resolve(self, world: "World") -> Any:
return some_math_calculation(world, self.modifier)
rule = Has("Combat Level", count=FromCustomResolution("combat"))
```
If you want to support rule serialization and your resolver contains non-serializable properties you may need to override `to_dict` or `from_dict`.
## Enabling caching
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
+1 -1
View File
@@ -131,7 +131,7 @@ Unless you configured PyCharm to use pytest as a test runner, you may get import
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the
Your working directory should be the root Archipelago directory and the script should be the
tests folder within your world.
You can also find the 'Archipelago Unittests' as an option in the dropdown at the top of the window
-1
View File
@@ -108,7 +108,6 @@ Example:
```json
{
...
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
"Factorio":"a699194a9589db3ebc0d821915864b422c782f44",
...
}
-6
View File
@@ -17,12 +17,6 @@
# Web hosting port
#PORT: 80
# Ports used for game hosting. Values can be specific ports, port ranges or both. Default is: [49152-65535, 0]
# Zero means it will use a random free port if there is no port in the next 1024 randomly chosen ports from the range
# Examples of valid values: [40000-41000, 49152-65535]
# If ports within the range(s) are already in use, the WebHost will fallback to the default [49152-65535, 0] range.
#GAME_PORTS: [49152-65535, 0]
# Place where uploads go.
#UPLOAD_FOLDER: uploads
-5
View File
@@ -98,11 +98,6 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
+17 -17
View File
@@ -1,21 +1,21 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.3
jellyfish>=1.2.1
jinja2>=3.1.6
schema>=0.7.8
kivy>=2.3.1
bsdiff4>=1.2.6
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
pathspec>=0.12.1
colorama==0.4.6
websockets==13.1 # ,<14
PyYAML==6.0.3
jellyfish==1.2.1
jinja2==3.1.6
schema==0.7.8
kivy==2.3.1
bsdiff4==1.2.6
platformdirs==4.9.4
certifi==2026.2.25
cython==3.2.4
cymem==2.0.13
orjson==3.11.7
typing_extensions==4.15.0
pyshortcuts==1.9.7
pathspec==1.0.4
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0
# Legacy world dependencies that custom worlds rely on
Pymem>=1.13.0
Pymem==1.14.0
+162
View File
@@ -0,0 +1,162 @@
import dataclasses
import importlib
from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast, overload
from typing_extensions import override
from Options import Option
if TYPE_CHECKING:
from worlds.AutoWorld import World
class FieldResolverRegister:
"""A container class to contain world custom resolvers"""
custom_resolvers: ClassVar[dict[str, dict[str, type["FieldResolver"]]]] = {}
"""
A mapping of game name to mapping of resolver name to resolver class
to hold custom resolvers implemented by worlds
"""
@classmethod
def get_resolver_cls(cls, game_name: str, resolver_name: str) -> type["FieldResolver"]:
"""Returns the world-registered or default resolver with the given name"""
custom_resolver_classes = cls.custom_resolvers.get(game_name, {})
if resolver_name not in DEFAULT_RESOLVERS and resolver_name not in custom_resolver_classes:
raise ValueError(f"Resolver '{resolver_name}' for game '{game_name}' not found")
return custom_resolver_classes.get(resolver_name) or DEFAULT_RESOLVERS[resolver_name]
@dataclasses.dataclass(frozen=True)
class FieldResolver(ABC):
@abstractmethod
def resolve(self, world: "World") -> Any: ...
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this resolver"""
fields = {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)}
return {
"resolver": self.__class__.__name__,
**fields,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""Returns a new instance of this resolver from a serialized dict representation"""
assert data.get("resolver", None) == cls.__name__
return cls(**{k: v for k, v in data.items() if k != "resolver"})
@override
def __str__(self) -> str:
return self.__class__.__name__
@classmethod
def __init_subclass__(cls, /, game: str) -> None:
if game != "Archipelago":
custom_resolvers = FieldResolverRegister.custom_resolvers.setdefault(game, {})
if cls.__qualname__ in custom_resolvers:
raise TypeError(f"Resolver {cls.__qualname__} has already been registered for game {game}")
custom_resolvers[cls.__qualname__] = cls
elif cls.__module__ != "rule_builder.field_resolvers":
raise TypeError("You cannot define custom resolvers for the base Archipelago world")
@dataclasses.dataclass(frozen=True)
class FromOption(FieldResolver, game="Archipelago"):
option: type[Option[Any]]
field: str = "value"
@override
def resolve(self, world: "World") -> Any:
option_name = next(
(name for name, cls in world.options.__class__.type_hints.items() if cls is self.option),
None,
)
if option_name is None:
raise ValueError(
f"Cannot find option {self.option.__name__} in options class {world.options.__class__.__name__}"
)
opt = cast(Option[Any] | None, getattr(world.options, option_name, None))
if opt is None:
raise ValueError(f"Invalid option: {option_name}")
return getattr(opt, self.field)
@override
def to_dict(self) -> dict[str, Any]:
return {
"resolver": "FromOption",
"option": f"{self.option.__module__}.{self.option.__name__}",
"field": self.field,
}
@override
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
if "option" not in data:
raise ValueError("Missing required option")
option_path = data["option"]
try:
option_mod_name, option_cls_name = option_path.rsplit(".", 1)
option_module = importlib.import_module(option_mod_name)
option = getattr(option_module, option_cls_name, None)
except (ValueError, ImportError) as e:
raise ValueError(f"Cannot parse option '{option_path}'") from e
if option is None or not issubclass(option, Option):
raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass")
return cls(cast(type[Option[Any]], option), data.get("field", "value"))
@override
def __str__(self) -> str:
field = f".{self.field}" if self.field != "value" else ""
return f"FromOption({self.option.__name__}{field})"
@dataclasses.dataclass(frozen=True)
class FromWorldAttr(FieldResolver, game="Archipelago"):
name: str
@override
def resolve(self, world: "World") -> Any:
obj: Any = world
for field in self.name.split("."):
if obj is None:
return None
if isinstance(obj, Mapping):
obj = obj.get(field, None) # pyright: ignore[reportUnknownMemberType]
else:
obj = getattr(obj, field, None)
return obj
@override
def __str__(self) -> str:
return f"FromWorldAttr({self.name})"
T = TypeVar("T")
@overload
def resolve_field(field: Any, world: "World", expected_type: type[T]) -> T: ...
@overload
def resolve_field(field: Any, world: "World", expected_type: None = None) -> Any: ...
def resolve_field(field: Any, world: "World", expected_type: type[T] | None = None) -> T | Any:
if isinstance(field, FieldResolver):
field = field.resolve(world)
if expected_type:
assert isinstance(field, expected_type), f"Expected type {expected_type} but got {type(field)}"
return field
DEFAULT_RESOLVERS = {
resolver_name: resolver_class
for resolver_name, resolver_class in locals().items()
if isinstance(resolver_class, type)
and issubclass(resolver_class, FieldResolver)
and resolver_class is not FieldResolver
}
+84 -31
View File
@@ -7,6 +7,7 @@ from typing_extensions import TypeVar, dataclass_transform, override
from BaseClasses import CollectionState
from NetUtils import JSONMessagePart
from .field_resolvers import FieldResolver, FieldResolverRegister, resolve_field
from .options import OptionFilter
if TYPE_CHECKING:
@@ -108,11 +109,14 @@ class Rule(Generic[TWorld]):
def to_dict(self) -> dict[str, Any]:
"""Returns a JSON compatible dict representation of this rule"""
args = {
field.name: getattr(self, field.name, None)
for field in dataclasses.fields(self)
if field.name not in ("options", "filtered_resolution")
}
args = {}
for field in dataclasses.fields(self):
if field.name in ("options", "filtered_resolution"):
continue
value = getattr(self, field.name, None)
if isinstance(value, FieldResolver):
value = value.to_dict()
args[field.name] = value
return {
"rule": self.__class__.__qualname__,
"options": [o.to_dict() for o in self.options],
@@ -124,7 +128,19 @@ class Rule(Generic[TWorld]):
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
"""Returns a new instance of this rule from a serialized dict representation"""
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False))
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
return cls(**args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@classmethod
def _parse_field_resolvers(cls, data: Mapping[str, Any], game_name: str) -> dict[str, Any]:
result: dict[str, Any] = {}
for name, value in data.items():
if isinstance(value, dict) and "resolver" in value:
resolver_cls = FieldResolverRegister.get_resolver_cls(game_name, value["resolver"]) # pyright: ignore[reportUnknownArgumentType]
result[name] = resolver_cls.from_dict(value) # pyright: ignore[reportUnknownArgumentType]
else:
result[name] = value
return result
def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]":
"""Combines two rules or a rule and an option filter into an And rule"""
@@ -527,7 +543,7 @@ class Or(NestedRule[TWorld], game="Archipelago"):
items[item] = 1
elif isinstance(child, HasAnyCount.Resolved):
for item, count in child.item_counts:
if item not in items or items[item] < count:
if item not in items or count < items[item]:
items[item] = count
else:
clauses.append(child)
@@ -688,24 +704,24 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"):
class Has(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has at least `count` of a given item"""
item_name: str
item_name: str | FieldResolver
"""The item to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The count the player is required to have"""
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
return self.Resolved(
self.item_name,
self.count,
resolve_field(self.item_name, world, str),
count=resolve_field(self.count, world, int),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name}{count}{options})"
@@ -991,7 +1007,7 @@ class HasAny(Rule[TWorld], game="Archipelago"):
class HasAllCounts(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has all of the specified counts of the given items"""
item_counts: dict[str, int]
item_counts: Mapping[str, int | FieldResolver]
"""A mapping of item name to count to check for"""
@override
@@ -1002,12 +1018,30 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
tuple(self.item_counts.items()),
item_counts,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1096,7 +1130,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"):
class HasAnyCount(Rule[TWorld], game="Archipelago"):
"""A rule that checks if the player has any of the specified counts of the given items"""
item_counts: dict[str, int]
item_counts: Mapping[str, int | FieldResolver]
"""A mapping of item name to count to check for"""
@override
@@ -1107,12 +1141,30 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"):
if len(self.item_counts) == 1:
item = next(iter(self.item_counts))
return Has(item, self.item_counts[item]).resolve(world)
item_counts = tuple((name, resolve_field(count, world, int)) for name, count in self.item_counts.items())
return self.Resolved(
tuple(self.item_counts.items()),
item_counts,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def to_dict(self) -> dict[str, Any]:
output = super().to_dict()
output["args"]["item_counts"] = {
key: value.to_dict() if isinstance(value, FieldResolver) else value
for key, value in output["args"]["item_counts"].items()
}
return output
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = data.get("args", {})
item_counts = cls._parse_field_resolvers(args.get("item_counts", {}), world_cls.game)
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(item_counts, options=options, filtered_resolution=data.get("filtered_resolution", False))
@override
def __str__(self) -> str:
items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()])
@@ -1204,13 +1256,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int = 1,
count: int | FieldResolver = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1227,7 +1279,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
return Has(self.item_names[0], self.count).resolve(world)
return self.Resolved(
self.item_names,
self.count,
count=resolve_field(self.count, world, int),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1235,7 +1287,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = {**data.get("args", {})}
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1338,13 +1390,13 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
item_names: tuple[str, ...]
"""A tuple of item names to check for"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
def __init__(
self,
*item_names: str,
count: int = 1,
count: int | FieldResolver = 1,
options: Iterable[OptionFilter] = (),
filtered_resolution: bool = False,
) -> None:
@@ -1354,14 +1406,15 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
def _instantiate(self, world: TWorld) -> Rule.Resolved:
if len(self.item_names) == 0 or len(self.item_names) < self.count:
count = resolve_field(self.count, world, int)
if len(self.item_names) == 0 or len(self.item_names) < count:
# match state.has_from_list_unique
return False_().resolve(world)
if len(self.item_names) == 1:
return Has(self.item_names[0]).resolve(world)
return self.Resolved(
self.item_names,
self.count,
count,
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@@ -1369,7 +1422,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
@override
@classmethod
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
args = {**data.get("args", {})}
args = cls._parse_field_resolvers(data.get("args", {}), world_cls.game)
item_names = args.pop("item_names", ())
options = OptionFilter.multiple_from_dict(data.get("options", ()))
return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False))
@@ -1468,7 +1521,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""The name of the item group containing the items"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
@override
@@ -1477,14 +1530,14 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
return self.Resolved(
self.item_name_group,
item_names,
self.count,
count=resolve_field(self.count, world, int),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
@@ -1542,7 +1595,7 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
item_name_group: str
"""The name of the item group containing the items"""
count: int = 1
count: int | FieldResolver = 1
"""The number of items the player needs to have"""
@override
@@ -1551,14 +1604,14 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
return self.Resolved(
self.item_name_group,
item_names,
self.count,
count=resolve_field(self.count, world, int),
player=world.player,
caching_enabled=getattr(world, "rule_caching_enabled", False),
)
@override
def __str__(self) -> str:
count = f", count={self.count}" if self.count > 1 else ""
count = f", count={self.count}" if isinstance(self.count, FieldResolver) or self.count > 1 else ""
options = f", options={self.options}" if self.options else ""
return f"{self.__class__.__name__}({self.item_name_group}{count}{options})"
+1 -2
View File
@@ -71,7 +71,6 @@ non_apworlds: set[str] = {
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Sudoku",
"Super Mario 64",
"VVVVVV",
"Wargroove",
@@ -658,7 +657,7 @@ cx_Freeze.setup(
options={
"build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": [],
"includes": ["rule_builder.cached_world"],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_includes": [],
+2 -2
View File
@@ -11,7 +11,7 @@ class TestImplemented(unittest.TestCase):
def test_completion_condition(self):
"""Ensure a completion condition is set that has requirements."""
for game_name, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden and game_name not in {"Sudoku"}:
if not world_type.hidden:
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
self.assertFalse(multiworld.completion_condition[1](multiworld.state))
@@ -59,7 +59,7 @@ class TestImplemented(unittest.TestCase):
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
+1 -1
View File
@@ -109,7 +109,7 @@ class TestOptions(unittest.TestCase):
def test_option_set_keys_random(self):
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
if game_name not in ("Archipelago", "Super Metroid"):
for option_key, option in world_type.options_dataclass.type_hints.items():
if issubclass(option, OptionSet):
with self.subTest(game=game_name, option=option_key):
+67 -8
View File
@@ -6,8 +6,9 @@ from typing_extensions import override
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from NetUtils import JSONMessagePart
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Range, Toggle
from rule_builder.cached_world import CachedRuleBuilderWorld
from rule_builder.field_resolvers import FieldResolver, FromOption, FromWorldAttr, resolve_field
from rule_builder.options import Operator, OptionFilter
from rule_builder.rules import (
And,
@@ -59,12 +60,20 @@ class SetOption(OptionSet):
valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride]
class RangeOption(Range):
auto_display_name = True
range_start = 1
range_end = 10
default = 5
@dataclass
class RuleBuilderOptions(PerGameCommonOptions):
toggle_option: ToggleOption
choice_option: ChoiceOption
text_option: FreeTextOption
set_option: SetOption
range_option: RangeOption
GAME_NAME = "Rule Builder Test Game"
@@ -233,6 +242,14 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})),
HasAny.Resolved(("A", "B", "C", "D", "E"), player=1),
),
(
And(HasAllCounts({"A": 1, "B": 2}), HasAllCounts({"A": 2, "B": 2})),
HasAllCounts.Resolved((("A", 2), ("B", 2)), player=1),
),
(
Or(HasAnyCount({"A": 1, "B": 2}), HasAnyCount({"A": 2, "B": 2})),
HasAnyCount.Resolved((("A", 1), ("B", 2)), player=1),
),
)
)
class TestSimplify(RuleBuilderTestCase):
@@ -651,14 +668,15 @@ class TestRules(RuleBuilderTestCase):
self.assertFalse(resolved_rule(self.state))
def test_has_any_count(self) -> None:
item_counts = {"Item 1": 1, "Item 2": 2}
item_counts: dict[str, int | FieldResolver] = {"Item 1": 1, "Item 2": 2}
rule = HasAnyCount(item_counts)
resolved_rule = rule.resolve(self.world)
self.world.register_rule_dependencies(resolved_rule)
for item_name, count in item_counts.items():
item = self.world.create_item(item_name)
for _ in range(count):
num_items = resolve_field(count, self.world, int)
for _ in range(num_items):
self.assertFalse(resolved_rule(self.state))
self.state.collect(item)
self.assertTrue(resolved_rule(self.state))
@@ -755,7 +773,7 @@ class TestSerialization(RuleBuilderTestCase):
rule: ClassVar[Rule[Any]] = And(
Or(
Has("i1", count=4),
Has("i1", count=FromOption(RangeOption)),
HasFromList("i2", "i3", "i4", count=2),
HasAnyCount({"i5": 2, "i6": 3}),
options=[OptionFilter(ToggleOption, 0)],
@@ -763,7 +781,7 @@ class TestSerialization(RuleBuilderTestCase):
Or(
HasAll("i7", "i8"),
HasAllCounts(
{"i9": 1, "i10": 5},
{"i9": 1, "i10": FromWorldAttr("instance_data.i10_count")},
options=[OptionFilter(ToggleOption, 1, operator="ne")],
filtered_resolution=True,
),
@@ -803,7 +821,14 @@ class TestSerialization(RuleBuilderTestCase):
"rule": "Has",
"options": [],
"filtered_resolution": False,
"args": {"item_name": "i1", "count": 4},
"args": {
"item_name": "i1",
"count": {
"resolver": "FromOption",
"option": "test.general.test_rule_builder.RangeOption",
"field": "value",
},
},
},
{
"rule": "HasFromList",
@@ -840,7 +865,12 @@ class TestSerialization(RuleBuilderTestCase):
},
],
"filtered_resolution": True,
"args": {"item_counts": {"i9": 1, "i10": 5}},
"args": {
"item_counts": {
"i9": 1,
"i10": {"resolver": "FromWorldAttr", "name": "instance_data.i10_count"},
}
},
},
{
"rule": "CanReachRegion",
@@ -915,7 +945,7 @@ class TestSerialization(RuleBuilderTestCase):
multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0)
world = multiworld.worlds[1]
deserialized_rule = world.rule_from_dict(self.rule_dict)
self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule))
self.assertEqual(deserialized_rule, self.rule, f"\n{deserialized_rule}\n{self.rule}")
class TestExplain(RuleBuilderTestCase):
@@ -1334,3 +1364,32 @@ class TestExplain(RuleBuilderTestCase):
"& False)",
)
assert str(self.resolved_rule) == " ".join(expected)
@classvar_matrix(
rules=(
(
Has("A", FromOption(RangeOption)),
Has.Resolved("A", count=5, player=1),
),
(
Has("B", FromWorldAttr("pre_calculated")),
Has.Resolved("B", count=3, player=1),
),
(
Has("C", FromWorldAttr("instance_data.key")),
Has.Resolved("C", count=7, player=1),
),
)
)
class TestFieldResolvers(RuleBuilderTestCase):
rules: ClassVar[tuple[Rule[Any], Rule.Resolved]]
def test_simplify(self) -> None:
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
world = multiworld.worlds[1]
world.pre_calculated = 3 # pyright: ignore[reportAttributeAccessIssue]
world.instance_data = {"key": 7} # pyright: ignore[reportAttributeAccessIssue]
rule, expected = self.rules
resolved_rule = rule.resolve(world)
self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}")
View File
-132
View File
@@ -1,132 +0,0 @@
import typing as t
from copy import deepcopy
from unittest import TestCase
from typing_extensions import override
import NetUtils
from NetUtils import GamesPackage
from apmw.multiserver.gamespackagecache import GamesPackageCache
class GamesPackageCacheTest(TestCase):
cache: GamesPackageCache
any_game: t.ClassVar[str] = "APQuest"
example_games_package: GamesPackage = {
"item_name_to_id": {"Item 1": 1},
"item_name_groups": {"Everything": ["Item 1"]},
"location_name_to_id": {"Location 1": 1},
"location_name_groups": {"Everywhere": ["Location 1"]},
"checksum": "1234",
}
@override
def setUp(self) -> None:
self.cache = GamesPackageCache()
def test_get_static_is_same(self) -> None:
"""Tests that get_static returns the same objects twice"""
reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get_static(self.any_game)
reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
self.assertIs(reduced_games_package1, reduced_games_package2)
self.assertIs(item_name_groups1, item_name_groups2)
self.assertIs(location_name_groups1, location_name_groups2)
def test_get_static_data_format(self) -> None:
"""Tests that get_static returns data in the correct format"""
reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
self.assertTrue(reduced_games_package["checksum"])
self.assertTrue(reduced_games_package["item_name_to_id"])
self.assertTrue(reduced_games_package["location_name_to_id"])
self.assertNotIn("item_name_groups", reduced_games_package)
self.assertNotIn("location_name_groups", reduced_games_package)
self.assertTrue(item_name_groups["Everything"])
self.assertTrue(location_name_groups["Everywhere"])
def test_get_static_is_serializable(self) -> None:
"""Tests that get_static returns data that can be serialized"""
NetUtils.encode(self.cache.get_static(self.any_game))
def test_get_static_missing_raises(self) -> None:
"""Tests that get_static raises KeyError if the world is missing"""
with self.assertRaises(KeyError):
_ = self.cache.get_static("Does not exist")
def test_eviction(self) -> None:
"""Tests that unused items get evicted from cache"""
game_name = "Test"
before_add = len(self.cache._reduced_games_packages)
data = self.cache.get(game_name, self.example_games_package)
self.assertTrue(data)
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
del data
if len(self.cache._reduced_games_packages) != before_add: # gc.collect() may not even be required
import gc
gc.collect()
self.assertEqual(before_add, len(self.cache._reduced_games_packages))
def test_get_required_field(self) -> None:
"""Tests that missing required field raises a KeyError"""
for field in ("item_name_to_id", "location_name_to_id", "item_name_groups"):
with self.subTest(field=field):
games_package = deepcopy(self.example_games_package)
del games_package[field] # type: ignore
with self.assertRaises(KeyError):
_ = self.cache.get(self.any_game, games_package)
def test_get_optional_properties(self) -> None:
"""Tests that missing optional field works"""
for field in ("checksum", "location_name_groups"):
with self.subTest(field=field):
games_package = deepcopy(self.example_games_package)
del games_package[field] # type: ignore
_, item_name_groups, location_name_groups = self.cache.get(self.any_game, games_package)
self.assertTrue(item_name_groups)
self.assertEqual(field != "location_name_groups", bool(location_name_groups))
def test_item_name_deduplication(self) -> None:
n = 1
s1 = f"Item {n}"
s2 = f"Item {n}"
# check if the deduplication is actually gonna do anything
self.assertIsNot(s1, s2)
self.assertEqual(s1, s2)
# do the thing
game_name = "Test"
games_package: GamesPackage = {
"item_name_to_id": {s1: n},
"item_name_groups": {"Everything": [s2]},
"location_name_to_id": {},
"location_name_groups": {},
"checksum": "1234",
}
reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
self.assertIs(
next(iter(reduced_games_package["item_name_to_id"].keys())),
item_name_groups["Everything"][0],
)
def test_location_name_deduplication(self) -> None:
n = 1
s1 = f"Location {n}"
s2 = f"Location {n}"
# check if the deduplication is actually gonna do anything
self.assertIsNot(s1, s2)
self.assertEqual(s1, s2)
# do the thing
game_name = "Test"
games_package: GamesPackage = {
"item_name_to_id": {},
"item_name_groups": {},
"location_name_to_id": {s1: n},
"location_name_groups": {"Everywhere": [s2]},
"checksum": "1234",
}
reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
self.assertIs(
next(iter(reduced_games_package["location_name_to_id"].keys())),
location_name_groups["Everywhere"][0],
)
+37
View File
@@ -0,0 +1,37 @@
"""Verify that NetUtils' enums work correctly with all supported Python versions."""
import pickle
import unittest
from enum import Enum
from typing import Type
from NetUtils import ClientStatus, HintStatus, SlotType
from Utils import restricted_loads
class Base:
class DataEnumTest(unittest.TestCase):
type: Type[Enum]
value: Enum
def test_unpickle(self) -> None:
"""Tests that enums used in multidata or multisave can be pickled and unpickled."""
pickled = pickle.dumps(self.value)
unpickled = restricted_loads(pickled)
self.assertEqual(unpickled, self.value)
self.assertIsInstance(unpickled, self.type)
class HintStatusTest(Base.DataEnumTest):
type = HintStatus
value = HintStatus.HINT_AVOID
class ClientStatusTest(Base.DataEnumTest):
type = ClientStatus
value = ClientStatus.CLIENT_GOAL
class SlotTypeTest(Base.DataEnumTest):
type = SlotType
value = SlotType.player
+97 -1
View File
@@ -1,6 +1,8 @@
import unittest
from Options import Choice, DefaultOnToggle, Toggle
from collections import Counter
from Options import Choice, DefaultOnToggle, Toggle, OptionDict, OptionError, OptionSet, OptionList, OptionCounter
class TestNumericOptions(unittest.TestCase):
@@ -74,3 +76,97 @@ class TestNumericOptions(unittest.TestCase):
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)
class TestContainerOptions(unittest.TestCase):
def test_option_dict(self):
class TestOptionDict(OptionDict):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_dict = {"D": "Foo"}
test_option_dict = TestOptionDict(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_dict.verify_keys)
init_dict = {"A": "foo", "B": "bar"}
test_option_dict = TestOptionDict(init_dict)
self.assertEqual(test_option_dict, init_dict) # Implicit value comparison
self.assertEqual(test_option_dict["A"], "foo")
self.assertIn("B", test_option_dict)
self.assertNotIn("C", test_option_dict)
self.assertRaises(KeyError, lambda: test_option_dict["C"])
def test_option_set(self):
class TestOptionSet(OptionSet):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_set = {"D"}
test_option_set = TestOptionSet(unknown_key_init_set)
self.assertRaises(OptionError, test_option_set.verify_keys)
init_set = {"A", "B"}
test_option_set = TestOptionSet(init_set)
self.assertEqual(test_option_set, init_set) # Implicit value comparison
self.assertIn("B", test_option_set)
self.assertNotIn("C", test_option_set)
def test_option_list(self):
class TestOptionList(OptionList):
valid_keys = frozenset({"A", "B", "C"})
unknown_key_init_list = ["D"]
test_option_list = TestOptionList(unknown_key_init_list)
self.assertRaises(OptionError, test_option_list.verify_keys)
init_list = ["A", "B"]
test_option_list = TestOptionList(init_list)
self.assertEqual(test_option_list, init_list)
self.assertIn("B", test_option_list)
self.assertNotIn("C", test_option_list)
def test_option_counter(self):
class TestOptionCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
max = 10
min = 0
unknown_key_init_dict = {"D": 5}
test_option_counter = TestOptionCounter(unknown_key_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_keys)
wrong_value_type_init_dict = {"A": "B"}
self.assertRaises(TypeError, TestOptionCounter, wrong_value_type_init_dict)
violates_max_init_dict = {"A": 5, "B": 11}
test_option_counter = TestOptionCounter(violates_max_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
violates_min_init_dict = {"A": -1, "B": 5}
test_option_counter = TestOptionCounter(violates_min_init_dict)
self.assertRaises(OptionError, test_option_counter.verify_values)
init_dict = {"A": 0, "B": 10}
test_option_counter = TestOptionCounter(init_dict)
self.assertEqual(test_option_counter, Counter(init_dict))
self.assertIn("A", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0)
self.assertEqual(test_option_counter["B"], 10)
self.assertEqual(test_option_counter["C"], 0)
def test_culling_option_counter(self):
class TestCullingCounter(OptionCounter):
valid_keys = frozenset({"A", "B", "C"})
cull_zeroes = True
init_dict = {"A": 0, "B": 10}
test_option_counter = TestCullingCounter(init_dict)
self.assertNotIn("A", test_option_counter)
self.assertIn("B", test_option_counter)
self.assertNotIn("C", test_option_counter)
self.assertEqual(test_option_counter["A"], 0) # It's still a Counter! cull_zeroes is about "in" checks.
self.assertEqual(test_option_counter, Counter({"B": 10}))
-86
View File
@@ -1,86 +0,0 @@
import os
import unittest
from Utils import is_macos
from WebHostLib.customserver import parse_game_ports, create_random_port_socket, get_used_ports
ci = bool(os.environ.get("CI"))
class TestPortAllocating(unittest.TestCase):
def test_parse_game_ports(self) -> None:
"""Ensure that game ports with ranges are parsed correctly"""
val = parse_game_ports(("1000-2000", "2000-5000", "1000-2000", 20, 40, "20", "0"))
self.assertListEqual(val.parsed_ports,
[range(1000, 2001), range(2000, 5001), range(1000, 2001), range(20, 21), range(40, 41),
range(20, 21)], "The parsed game ports are not the expected values")
self.assertTrue(val.ephemeral_allowed, "The ephemeral allowed flag is not set even though it was passed")
self.assertListEqual(val.weights, [1001, 4002, 5003, 5004, 5005, 5006],
"Cumulative weights are not the expected value")
val = parse_game_ports(())
self.assertListEqual(val.parsed_ports, [], "Empty list of game port returned something")
self.assertFalse(val.ephemeral_allowed, "Empty list returned that ephemeral is allowed")
val = parse_game_ports((0,))
self.assertListEqual(val.parsed_ports, [], "Empty list of ranges returned something")
self.assertTrue(val.ephemeral_allowed, "List with just 0 is not allowing ephemeral ports")
val = parse_game_ports((1,))
self.assertEqual(val.parsed_ports, [range(1, 2)], "Parsed ports doesn't contain the expected values")
self.assertFalse(val.ephemeral_allowed, "List with just single port returned that ephemeral is allowed")
def test_parse_game_port_errors(self) -> None:
"""Ensure that game ports with incorrect values raise the expected error"""
with self.assertRaises(ValueError, msg="Negative numbers didn't get interpreted as an invalid range"):
parse_game_ports(tuple("-50215"))
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number"):
parse_game_ports(tuple("dwafawg"))
with self.assertRaises(
ValueError,
msg="A range with an extra dash at the end didn't get interpreted as an invalid number because of it's end dash"
):
parse_game_ports(tuple("20-21215-"))
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number for the start of a range"):
parse_game_ports(tuple("f-21215"))
def test_random_port_socket_edge_cases(self) -> None:
"""Verify if edge cases on creation of random port socket is working fine"""
# Try giving an empty tuple and fail over it
with self.assertRaises(OSError) as err:
create_random_port_socket(tuple(), "127.0.0.1")
self.assertEqual(err.exception.errno, 98, "Raised an unexpected error code")
self.assertEqual(err.exception.strerror, "No available ports", "Raised an unexpected error string")
# Try only having ephemeral ports enabled
try:
create_random_port_socket(("0",), "127.0.0.1").close()
except OSError as err:
self.assertEqual(err.errno, 98, "Raised an unexpected error code")
# If it returns our error string that means something is wrong with our code
self.assertNotEqual(err.strerror, "No available ports",
"Raised an unexpected error string")
@unittest.skipUnless(ci, "can't guarantee free ports outside of CI")
def test_random_port_socket(self) -> None:
"""Verify if returned sockets use the correct port ranges"""
sockets = []
for _ in range(6):
socket = create_random_port_socket(("8080-8085",), "127.0.0.1")
sockets.append(socket)
_, port = socket.getsockname()
self.assertIn(port, range(8080, 8086), "Port of socket was not inside the expected range")
for s in sockets:
s.close()
sockets.clear()
length = 5_000 if is_macos else (30_000 - len(get_used_ports()))
for _ in range(length):
socket = create_random_port_socket(("30000-65535",), "127.0.0.1")
sockets.append(socket)
_, port = socket.getsockname()
self.assertIn(port, range(30_000, 65536), "Port of socket was not inside the expected range")
for s in sockets:
s.close()
+76
View File
@@ -0,0 +1,76 @@
import math
from typing import Any, Callable
from typing_extensions import override
from uuid import uuid4
from werkzeug.routing import BaseConverter
from . import TestBase
class TestSUUID(TestBase):
converter: BaseConverter
filter: Callable[[Any], str]
@override
def setUp(self) -> None:
from werkzeug.routing import Map
super().setUp()
self.converter = self.app.url_map.converters["suuid"](Map())
self.filter = self.app.jinja_env.filters["suuid"] # type: ignore # defines how we use it, not what it can be
def test_is_reversible(self) -> None:
u = uuid4()
self.assertEqual(u, self.converter.to_python(self.converter.to_url(u)))
s = "A" * 22 # uuid with all zeros
self.assertEqual(s, self.converter.to_url(self.converter.to_python(s)))
def test_uuid_length(self) -> None:
with self.assertRaises(ValueError):
self.converter.to_python("AAAA")
def test_padding(self) -> None:
self.converter.to_python("A" * 22) # check that the correct value works
with self.assertRaises(ValueError):
self.converter.to_python("A" * 22 + "==") # converter should not allow padding
def test_empty(self) -> None:
with self.assertRaises(ValueError):
self.converter.to_python("")
def test_stray_equal_signs(self) -> None:
self.converter.to_python("A" * 22) # check that the correct value works
with self.assertRaises(ValueError):
self.converter.to_python("A" * 22 + "==" + "AA") # the "==AA" should not be ignored, but error out
with self.assertRaises(ValueError):
self.converter.to_python("A" * 20 + "==" + "AA") # the final "A"s should not be appended to the first "A"s
def test_stray_whitespace(self) -> None:
s = "A" * 22
self.converter.to_python(s) # check that the correct value works
for char in " \t\r\n\v":
for pos in (0, 11, 22):
with self.subTest(char=char, pos=pos):
s_with_whitespace = s[0:pos] + char * 4 + s[pos:] # insert 4 to make padding correct
# check that the constructed s_with_whitespace is correct
self.assertEqual(len(s_with_whitespace), len(s) + 4)
self.assertEqual(s_with_whitespace[pos], char)
# s_with_whitespace should be invalid as SUUID
with self.assertRaises(ValueError):
self.converter.to_python(s_with_whitespace)
def test_filter_returns_valid_string(self) -> None:
u = uuid4()
s = self.filter(u)
self.assertIsInstance(s, str)
self.assertNotIn("=", s)
self.assertEqual(len(s), math.ceil(len(u.bytes) * 4 / 3))
def test_filter_is_same_as_converter(self) -> None:
u = uuid4()
self.assertEqual(self.filter(u), self.converter.to_url(u))
def test_filter_bad_type(self) -> None:
with self.assertRaises(Exception): # currently the type is not checked directly, so any exception is valid
self.filter(None)
@@ -1,147 +0,0 @@
import typing as t
from copy import deepcopy
from typing_extensions import override
from test.multiserver.test_gamespackage_cache import GamesPackageCacheTest
import Utils
from NetUtils import GamesPackage
from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
class FakeGameDataPackage:
_rows: "t.ClassVar[dict[str, FakeGameDataPackage]]" = {}
data: bytes
@classmethod
def get(cls, checksum: str) -> "FakeGameDataPackage | None":
return cls._rows.get(checksum, None)
@classmethod
def add(cls, checksum: str, full_games_package: GamesPackage) -> None:
row = FakeGameDataPackage()
row.data = Utils.restricted_dumps(full_games_package)
cls._rows[checksum] = row
class DBGamesPackageCacheTest(GamesPackageCacheTest):
cache: DBGamesPackageCache
any_game: t.ClassVar[str] = "My Game"
static_data: t.ClassVar[dict[str, GamesPackage]] = { # noqa: pycharm doesn't understand this
"My Game": {
"item_name_to_id": {"Item 1": 1},
"location_name_to_id": {"Location 1": 1},
"item_name_groups": {"Everything": ["Item 1"]},
"location_name_groups": {"Everywhere": ["Location 1"]},
"checksum": "2345",
}
}
orig_db_type: t.ClassVar[type]
@override
@classmethod
def setUpClass(cls) -> None:
import WebHostLib.models
cls.orig_db_type = WebHostLib.models.GameDataPackage
WebHostLib.models.GameDataPackage = FakeGameDataPackage # type: ignore
@override
def setUp(self) -> None:
self.cache = DBGamesPackageCache(self.static_data)
@override
@classmethod
def tearDownClass(cls) -> None:
import WebHostLib.models
WebHostLib.models.GameDataPackage = cls.orig_db_type # type: ignore
def assert_conversion(
self,
full_games_package: GamesPackage,
reduced_games_package: dict[str, t.Any],
item_name_groups: dict[str, t.Any],
location_name_groups: dict[str, t.Any],
) -> None:
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
if key in full_games_package:
self.assertEqual(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
self.assertEqual(item_name_groups, full_games_package["item_name_groups"])
self.assertEqual(location_name_groups, full_games_package["location_name_groups"])
def assert_static_conversion(
self,
full_games_package: GamesPackage,
reduced_games_package: dict[str, t.Any],
item_name_groups: dict[str, t.Any],
location_name_groups: dict[str, t.Any],
) -> None:
self.assert_conversion(full_games_package, reduced_games_package, item_name_groups, location_name_groups)
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
self.assertIs(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
def test_get_static_contents(self) -> None:
"""Tests that get_static returns the correct data"""
reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
self.assertIs(reduced_games_package[key], self.static_data[self.any_game][key]) # noqa: pycharm
self.assertEqual(item_name_groups, self.static_data[self.any_game]["item_name_groups"])
self.assertEqual(location_name_groups, self.static_data[self.any_game]["location_name_groups"])
def test_static_not_evicted(self) -> None:
"""Tests that static data is not evicted from cache during gc"""
import gc
game_name = next(iter(self.static_data.keys()))
ids = [id(o) for o in self.cache.get_static(game_name)]
gc.collect()
self.assertEqual(ids, [id(o) for o in self.cache.get_static(game_name)])
def test_get_is_static(self) -> None:
"""Tests that a get with correct checksum return the static items"""
# NOTE: this is only true for the DB cache, not the "regular" one, since we want to avoid loading worlds there
cks: GamesPackage = {"checksum": self.static_data[self.any_game]["checksum"]} # noqa: pycharm doesn't like this
reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get(self.any_game, cks)
reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
self.assertIs(reduced_games_package1, reduced_games_package2)
self.assertEqual(location_name_groups1, location_name_groups2)
self.assertEqual(item_name_groups1, item_name_groups2)
def test_get_from_db(self) -> None:
"""Tests that a get with only checksum will load the full data from db and is cached"""
game_name = "Another Game"
full_games_package = deepcopy(self.static_data[self.any_game])
full_games_package["checksum"] = "3456"
cks: GamesPackage = {"checksum": full_games_package["checksum"]} # noqa: pycharm doesn't like this
FakeGameDataPackage.add(full_games_package["checksum"], full_games_package)
before_add = len(self.cache._reduced_games_packages)
data = self.cache.get(game_name, cks)
self.assert_conversion(full_games_package, *data) # type: ignore
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
def test_get_missing_from_db_uses_full_games_package(self) -> None:
"""Tests that a get with full data (missing from db) will use the full data and is cached"""
game_name = "Yet Another Game"
full_games_package = deepcopy(self.static_data[self.any_game])
full_games_package["checksum"] = "4567"
before_add = len(self.cache._reduced_games_packages)
data = self.cache.get(game_name, full_games_package)
self.assert_conversion(full_games_package, *data) # type: ignore
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
def test_get_without_checksum_uses_full_games_package(self) -> None:
"""Tests that a get with full data and no checksum will use the full data and is not cached"""
game_name = "Yet Another Game"
full_games_package = deepcopy(self.static_data[self.any_game])
del full_games_package["checksum"]
before_add = len(self.cache._reduced_games_packages)
data = self.cache.get(game_name, full_games_package)
self.assert_conversion(full_games_package, *data) # type: ignore
self.assertEqual(before_add, len(self.cache._reduced_games_packages))
def test_get_missing_from_db_raises(self) -> None:
"""Tests that a get that requires a row to exist raise an exception if it doesn't"""
with self.assertRaises(Exception):
_ = self.cache.get("Does not exist", {"checksum": "0000"})
+11 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import enum
import hashlib
import logging
import pathlib
@@ -20,6 +21,15 @@ if TYPE_CHECKING:
from NetUtils import GamesPackage, MultiData
from settings import Group
class FillerReason(enum.StrEnum):
undefined = enum.auto()
item_link = enum.auto()
panic_fill = enum.auto()
start_inventory_from_pool = enum.auto()
world = enum.auto()
perf_logger = logging.getLogger("performance")
@@ -577,7 +587,7 @@ class World(metaclass=AutoWorldRegister):
pass
# following methods should not need to be overridden.
def create_filler(self) -> "Item":
def create_filler(self, reason: FillerReason = FillerReason.undefined) -> "Item":
return self.create_item(self.get_filler_item_name())
# convenience methods
+6 -3
View File
@@ -269,8 +269,9 @@ if not is_frozen():
from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
parser = argparse.ArgumentParser(prog="Build APWorlds", description="Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="names of APWorlds to build")
parser.add_argument("--skip_open_folder", action="store_true", help="don't open the output build folder")
args = parser.parse_args(launch_args)
if args.worlds:
@@ -320,7 +321,9 @@ if not is_frozen():
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder)
if not args.skip_open_folder:
open_folder(apworlds_folder)
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))
+4 -4
View File
@@ -3,10 +3,10 @@ from Options import PerGameCommonOptions
from .Locations import location_table, AdventureLocation, dragon_room_to_region
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
def connect(multiworld: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
one_way=False, name=None):
source_region = world.get_region(source, player)
target_region = world.get_region(target, player)
source_region = multiworld.get_region(source, player)
target_region = multiworld.get_region(target, player)
if name is None:
name = source + " to " + target
@@ -22,7 +22,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
source_region.exits.append(connection)
connection.connect(target_region)
if not one_way:
connect(world, player, target, source, rule, True)
connect(multiworld, player, target, source, rule, True)
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
+25 -25
View File
@@ -3,47 +3,47 @@ from worlds.generic.Rules import add_rule, set_rule, forbid_item
def set_rules(self) -> None:
world = self.multiworld
multiworld = self.multiworld
use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
set_rule(world.get_entrance("YellowCastlePort", self.player),
set_rule(multiworld.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player))
set_rule(world.get_entrance("BlackCastlePort", self.player),
set_rule(multiworld.get_entrance("BlackCastlePort", self.player),
lambda state: state.has("Black Key", self.player))
set_rule(world.get_entrance("WhiteCastlePort", self.player),
set_rule(multiworld.get_entrance("WhiteCastlePort", self.player),
lambda state: state.has("White Key", self.player))
# a future thing would be to make the bat an actual item, or at least allow it to
# be placed in a castle, which would require some additions to the rules when
# use_bat_logic is true
if not use_bat_logic:
set_rule(world.get_entrance("WhiteCastleSecretPassage", self.player),
set_rule(multiworld.get_entrance("WhiteCastleSecretPassage", self.player),
lambda state: state.has("Bridge", self.player))
set_rule(world.get_entrance("WhiteCastlePeekPassage", self.player),
set_rule(multiworld.get_entrance("WhiteCastlePeekPassage", self.player),
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
set_rule(world.get_entrance("BlackCastleVaultEntrance", self.player),
set_rule(multiworld.get_entrance("BlackCastleVaultEntrance", self.player),
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
dragon_slay_check = self.options.dragon_slay_check.value
if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(world.get_location("Slay Yorgle", self.player),
set_rule(multiworld.get_location("Slay Yorgle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
set_rule(world.get_location("Slay Grundle", self.player),
set_rule(multiworld.get_location("Slay Grundle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
set_rule(world.get_location("Slay Rhindle", self.player),
set_rule(multiworld.get_location("Slay Rhindle", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Right Difficulty Switch", self.player))
else:
set_rule(world.get_location("Slay Yorgle", self.player),
set_rule(multiworld.get_location("Slay Yorgle", self.player),
lambda state: state.has("Sword", self.player))
set_rule(world.get_location("Slay Grundle", self.player),
set_rule(multiworld.get_location("Slay Grundle", self.player),
lambda state: state.has("Sword", self.player))
set_rule(world.get_location("Slay Rhindle", self.player),
set_rule(multiworld.get_location("Slay Rhindle", self.player),
lambda state: state.has("Sword", self.player))
# really this requires getting the dot item, and having another item or enemy
@@ -51,37 +51,37 @@ def set_rules(self) -> None:
# to actually make randomized, since it is invisible. May add some options
# for how that works in the distant future, but for now, just say you need
# the bridge and black key to get to it, as that simplifies things a lot
set_rule(world.get_entrance("CreditsWall", self.player),
set_rule(multiworld.get_entrance("CreditsWall", self.player),
lambda state: state.has("Bridge", self.player) and
state.has("Black Key", self.player))
if not use_bat_logic:
set_rule(world.get_entrance("CreditsToFarSide", self.player),
set_rule(multiworld.get_entrance("CreditsToFarSide", self.player),
lambda state: state.has("Magnet", self.player))
# bridge literally does not fit in this space, I think. I'll just exclude it
forbid_item(world.get_location("Dungeon Vault", self.player), "Bridge", self.player)
forbid_item(multiworld.get_location("Dungeon Vault", self.player), "Bridge", self.player)
# don't put magnet in locations that can pull in-logic items out of reach unless the bat is in play
if not use_bat_logic:
forbid_item(world.get_location("Dungeon Vault", self.player), "Magnet", self.player)
forbid_item(world.get_location("Red Maze Vault Entrance", self.player), "Magnet", self.player)
forbid_item(world.get_location("Credits Right Side", self.player), "Magnet", self.player)
forbid_item(multiworld.get_location("Dungeon Vault", self.player), "Magnet", self.player)
forbid_item(multiworld.get_location("Red Maze Vault Entrance", self.player), "Magnet", self.player)
forbid_item(multiworld.get_location("Credits Right Side", self.player), "Magnet", self.player)
# and obviously we don't want to start with the game already won
forbid_item(world.get_location("Inside Yellow Castle", self.player), "Chalice", self.player)
overworld = world.get_region("Overworld", self.player)
forbid_item(multiworld.get_location("Inside Yellow Castle", self.player), "Chalice", self.player)
overworld = multiworld.get_region("Overworld", self.player)
for loc in overworld.locations:
forbid_item(loc, "Chalice", self.player)
add_rule(world.get_location("Chalice Home", self.player),
add_rule(multiworld.get_location("Chalice Home", self.player),
lambda state: state.has("Chalice", self.player) and state.has("Yellow Key", self.player))
# world.random.choice(overworld.locations).progress_type = LocationProgressType.PRIORITY
# multiworld.random.choice(overworld.locations).progress_type = LocationProgressType.PRIORITY
# all_locations = world.get_locations(self.player).copy()
# all_locations = multiworld.get_locations(self.player).copy()
# while priority_count < get_num_items():
# loc = world.random.choice(all_locations)
# loc = multiworld.random.choice(all_locations)
# if loc.progress_type == LocationProgressType.DEFAULT:
# loc.progress_type = LocationProgressType.PRIORITY
# priority_count += 1
+2 -2
View File
@@ -105,8 +105,8 @@ class AdventureWorld(World):
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
required_client_version: Tuple[int, int, int] = (0, 3, 9)
def __init__(self, world: MultiWorld, player: int):
super().__init__(world, player)
def __init__(self, multiworld: MultiWorld, player: int):
super().__init__(multiworld, player)
self.rom_name: Optional[bytearray] = bytearray("", "utf8" )
self.dragon_rooms: [int] = [0x14, 0x19, 0x4]
self.dragon_slay_check: Optional[int] = 0
+2 -2
View File
@@ -1,2 +1,2 @@
maseya-z3pr>=1.0.0rc1
xxtea>=3.0.0
maseya-z3pr==1.0.0rc1
xxtea==3.7.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"game": "APQuest",
"minimum_ap_version": "0.6.4",
"world_version": "1.0.1",
"minimum_ap_version": "0.6.7",
"world_version": "2.0.0",
"authors": ["NewSoupVi"]
}
+4 -1
View File
@@ -30,7 +30,10 @@
C to fire available Confetti Cannons
Number Keys + Backspace for Math Trap\n
Rebinding controls might be added in the future :)"""
[b]Click to move also works![/b]
Click/tap Confetti Cannon to fire it
Submit Math Trap solution in the command line at the bottom"""
<VolumeSliderView>:
orientation: "horizontal"
+67 -3
View File
@@ -4,8 +4,9 @@ from argparse import Namespace
from enum import Enum
from typing import TYPE_CHECKING, Any
from CommonClient import CommonContext, gui_enabled, logger, server_loop
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop
from NetUtils import ClientStatus
from Utils import gui_enabled
from ..game.events import ConfettiFired, LocationClearedEvent, MathProblemSolved, MathProblemStarted, VictoryEvent
from ..game.game import Game
@@ -41,6 +42,16 @@ class ConnectionStatus(Enum):
GAME_RUNNING = 3
class APQuestClientCommandProcessor(ClientCommandProcessor):
ctx: "APQuestContext"
def default(self, raw: str) -> None:
if self.ctx.external_math_trap_input(raw):
return
super().default(raw)
class APQuestContext(CommonContext):
game = "APQuest"
items_handling = 0b111 # full remote
@@ -65,6 +76,7 @@ class APQuestContext(CommonContext):
delay_intro_song: bool
ui: APQuestManager
command_processor = APQuestClientCommandProcessor
def __init__(
self, server_address: str | None = None, password: str | None = None, delay_intro_song: bool = False
@@ -172,7 +184,6 @@ class APQuestContext(CommonContext):
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()
@@ -187,7 +198,7 @@ class APQuestContext(CommonContext):
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.ui.render(self.ap_quest_game, self.player_sprite, self.hard_mode)
self.handle_game_events()
def location_checked_side_effects(self, location: int) -> None:
@@ -244,6 +255,59 @@ class APQuestContext(CommonContext):
self.ap_quest_game.input(input_key)
self.render()
def queue_auto_move(self, target_x: int, target_y: int) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
if not self.ui.game_view.focused > 1: # Must already be in focus
return
self.ap_quest_game.queue_auto_move(target_x, target_y)
self.ui.start_auto_move()
def do_auto_move_and_rerender(self) -> None:
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
changed = self.ap_quest_game.do_auto_move()
if changed:
self.render()
def confetti_and_rerender(self) -> None:
# Used by tap mode
if self.ap_quest_game is None:
return
if not self.ap_quest_game.gameboard.ready:
return
if self.ap_quest_game.attempt_fire_confetti_cannon():
self.render()
def external_math_trap_input(self, raw: str) -> bool:
if self.ap_quest_game is None:
return False
if not self.ap_quest_game.gameboard.ready:
return False
if not self.ap_quest_game.active_math_problem:
return False
raw = raw.strip()
if not raw:
return False
if not raw.isnumeric():
return False
self.ap_quest_game.math_problem_replace([int(digit) for digit in raw])
if not self.ap_quest_game.active_math_problem:
self.ui.game_view.force_focus()
self.render()
return True
def make_gui(self) -> "type[kvui.GameManager]":
self.load_kv()
return APQuestManager
+80 -30
View File
@@ -4,29 +4,26 @@ from math import sqrt
from random import choice, random
from typing import Any
from kivy.core.window import Keyboard, Window
from kivy.core.window import Window
from kivy.graphics import Color, Triangle
from kivy.graphics.instructions import Canvas
from kivy.input import MotionEvent
from kivy.uix.behaviors import ButtonBehavior
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image
from kivy.uix.widget import Widget
from kivymd.uix.recycleview import MDRecycleView
from CommonClient import logger
from ..game.inputs import Input
INPUT_MAP = {
"up": Input.UP,
INPUT_MAP_STR = {
"w": Input.UP,
"down": Input.DOWN,
"s": Input.DOWN,
"right": Input.RIGHT,
"d": Input.RIGHT,
"left": Input.LEFT,
"a": Input.LEFT,
"spacebar": Input.ACTION,
" ": Input.ACTION,
"c": Input.CONFETTI,
"0": Input.ZERO,
"1": Input.ONE,
@@ -38,38 +35,52 @@ INPUT_MAP = {
"7": Input.SEVEN,
"8": Input.EIGHT,
"9": Input.NINE,
"backspace": Input.BACKSPACE,
}
INPUT_MAP_SPECIAL_INT = {
# Arrow Keys and Backspace
273: Input.UP,
274: Input.DOWN,
275: Input.RIGHT,
276: Input.LEFT,
8: Input.BACKSPACE,
}
class APQuestGameView(MDRecycleView):
_keyboard: Keyboard | None = None
focused: int = 1
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()
Window.bind(on_key_down=self._on_keyboard_down)
Window.bind(on_touch_down=self.check_focus)
self.opacity = 0.5
def on_touch_down(self, touch: MotionEvent) -> None:
self.bind_keyboard()
def bind_keyboard(self) -> None:
if self._keyboard is not None:
def check_focus(self, _, touch, *args, **kwargs) -> None:
if self.parent.collide_point(*touch.pos):
self.focused += 1
self.opacity = 1
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
self.focused = 0
self.opacity = 0.5
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
def force_focus(self) -> None:
Window.release_keyboard()
self.focused = 1
self.opacity = 1
def _on_keyboard_down(self, _: Any, keycode_int: int, _2: Any, keycode: str, _4: Any) -> bool:
if not self.focused:
return False
if keycode in INPUT_MAP_STR:
self.input_function(INPUT_MAP_STR[keycode])
elif keycode_int in INPUT_MAP_SPECIAL_INT:
self.input_function(INPUT_MAP_SPECIAL_INT[keycode_int])
return False
class APQuestGrid(GridLayout):
@@ -77,7 +88,7 @@ class APQuestGrid(GridLayout):
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
self_height_according_to_parent_width = parent_width * 11 / 12
if self_width_according_to_parent_height > parent_width:
self.size = parent_width, self_height_according_to_parent_width
@@ -203,13 +214,23 @@ class Confetti:
return True
class ConfettiView(MDRecycleView):
class ConfettiView(Widget):
confetti: list[Confetti]
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.confetti = []
# Don't eat tap events for the game grid under the confetti view
def on_touch_down(self, touch) -> bool:
return False
def on_touch_move(self, touch) -> bool:
return False
def on_touch_up(self, touch) -> bool:
return False
def check_resize(self, _: int, _1: int) -> None:
parent_width, parent_height = self.parent.size
@@ -254,3 +275,32 @@ class VolumeSliderView(BoxLayout):
class APQuestControlsView(BoxLayout):
pass
class TapImage(ButtonBehavior, Image):
callback: Callable[[], None]
def __init__(self, callback: Callable[[], None], **kwargs) -> None:
self.callback = callback
super().__init__(**kwargs)
def on_release(self) -> bool:
self.callback()
return True
class TapIfConfettiCannonImage(ButtonBehavior, Image):
callback: Callable[[], None]
is_confetti_cannon: bool = False
def __init__(self, callback: Callable[[], None], **kwargs: dict[str, Any]) -> None:
self.callback = callback
super().__init__(**kwargs)
def on_release(self) -> bool:
if self.is_confetti_cannon:
self.callback()
return True
+40 -13
View File
@@ -6,6 +6,7 @@ from kvui import GameManager, MDNavigationItemBase
# isort: on
from typing import TYPE_CHECKING, Any
from kivy._clock import ClockEvent
from kivy.clock import Clock
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import Image
@@ -13,7 +14,16 @@ from kivy.uix.layout import Layout
from kivymd.uix.recycleview import MDRecycleView
from ..game.game import Game
from .custom_views import APQuestControlsView, APQuestGameView, APQuestGrid, ConfettiView, VolumeSliderView
from ..game.graphics import Graphic
from .custom_views import (
APQuestControlsView,
APQuestGameView,
APQuestGrid,
ConfettiView,
TapIfConfettiCannonImage,
TapImage,
VolumeSliderView,
)
from .graphics import PlayerSprite, get_texture
from .sounds import SoundManager
@@ -28,15 +38,17 @@ class APQuestManager(GameManager):
lower_game_grid: GridLayout
upper_game_grid: GridLayout
game_view: MDRecycleView
game_view: MDRecycleView | None = None
game_view_tab: MDNavigationItemBase
sound_manager: SoundManager
bottom_image_grid: list[list[Image]]
top_image_grid: list[list[Image]]
top_image_grid: list[list[TapImage]]
confetti_view: ConfettiView
move_event: ClockEvent | None
bottom_grid_is_grass: bool
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -45,6 +57,7 @@ class APQuestManager(GameManager):
self.sound_manager.allow_intro_to_play = not self.ctx.delay_intro_song
self.top_image_grid = []
self.bottom_image_grid = []
self.move_event = None
self.bottom_grid_is_grass = False
def allow_intro_song(self) -> None:
@@ -71,25 +84,27 @@ class APQuestManager(GameManager):
def game_started(self) -> None:
self.switch_to_game_tab()
if self.game_view is not None:
self.game_view.force_focus()
self.sound_manager.game_started = True
def render(self, game: Game, player_sprite: PlayerSprite) -> None:
self.setup_game_grid_if_not_setup(game.gameboard.size)
def render(self, game: Game, player_sprite: PlayerSprite, hard_mode: bool) -> None:
self.setup_game_grid_if_not_setup(game)
# This calls game.render(), which needs to happen to update the state of math traps
self.render_gameboard(game, player_sprite)
self.render_gameboard(game, player_sprite, hard_mode)
# Only now can we check whether a math problem is active
self.render_background_game_grid(game.gameboard.size, game.active_math_problem is None)
self.sound_manager.math_trap_active = game.active_math_problem is not None
self.render_item_column(game)
def render_gameboard(self, game: Game, player_sprite: PlayerSprite) -> None:
def render_gameboard(self, game: Game, player_sprite: PlayerSprite, hard_mode: bool) -> None:
rendered_gameboard = game.render()
for gameboard_row, image_row in zip(rendered_gameboard, self.top_image_grid, strict=False):
for graphic, image in zip(gameboard_row, image_row[:11], strict=False):
texture = get_texture(graphic, player_sprite)
texture = get_texture(graphic, player_sprite, hard_mode)
if texture is None:
image.opacity = 0
@@ -104,6 +119,8 @@ class APQuestManager(GameManager):
for item_graphic, image_row in zip(rendered_item_column, self.top_image_grid, strict=False):
image = image_row[-1]
image.is_confetti_cannon = item_graphic == Graphic.CONFETTI_CANNON
texture = get_texture(item_graphic)
if texture is None:
image.opacity = 0
@@ -136,23 +153,25 @@ class APQuestManager(GameManager):
self.bottom_grid_is_grass = grass
def setup_game_grid_if_not_setup(self, size: tuple[int, int]) -> None:
def setup_game_grid_if_not_setup(self, game: Game) -> None:
if self.upper_game_grid.children:
return
self.top_image_grid = []
self.bottom_image_grid = []
for _row in range(size[1]):
size = game.gameboard.size
for row in range(size[1]):
self.top_image_grid.append([])
self.bottom_image_grid.append([])
for _column in range(size[0]):
for column in range(size[0]):
bottom_image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(bottom_image)
self.bottom_image_grid[-1].append(bottom_image)
top_image = Image(fit_mode="fill")
top_image = TapImage(lambda y=row, x=column: self.ctx.queue_auto_move(x, y), fit_mode="fill")
self.upper_game_grid.add_widget(top_image)
self.top_image_grid[-1].append(top_image)
@@ -160,11 +179,19 @@ class APQuestManager(GameManager):
image = Image(fit_mode="fill", color=(0.3, 0.3, 0.3))
self.lower_game_grid.add_widget(image)
image2 = Image(fit_mode="fill", opacity=0)
image2 = TapIfConfettiCannonImage(lambda: self.ctx.confetti_and_rerender(), fit_mode="fill", opacity=0)
self.upper_game_grid.add_widget(image2)
self.top_image_grid[-1].append(image2)
def start_auto_move(self) -> None:
if self.move_event is not None:
self.move_event.cancel()
self.ctx.do_auto_move_and_rerender()
self.move_event = Clock.schedule_interval(lambda _: self.ctx.do_auto_move_and_rerender(), 0.10)
def build(self) -> Layout:
container = super().build()
+17 -12
View File
@@ -1,10 +1,10 @@
import pkgutil
from collections.abc import Buffer
from enum import Enum
from io import BytesIO
from typing import Literal, NamedTuple, Protocol, cast
from kivy.uix.image import CoreImage
from typing_extensions import Buffer
from CommonClient import logger
@@ -29,6 +29,7 @@ class RelatedTexture(NamedTuple):
IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
# Inanimates
Graphic.WALL: RelatedTexture("inanimates.png", 16, 32, 16, 16),
Graphic.BREAKABLE_BLOCK: RelatedTexture("inanimates.png", 32, 32, 16, 16),
Graphic.CHEST: RelatedTexture("inanimates.png", 0, 16, 16, 16),
@@ -37,29 +38,25 @@ IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
Graphic.BUTTON_NOT_ACTIVATED: RelatedTexture("inanimates.png", 0, 0, 16, 16),
Graphic.BUTTON_ACTIVATED: RelatedTexture("inanimates.png", 16, 0, 16, 16),
Graphic.BUTTON_DOOR: RelatedTexture("inanimates.png", 32, 0, 16, 16),
# Enemies
Graphic.NORMAL_ENEMY_1_HEALTH: RelatedTexture("normal_enemy.png", 0, 0, 16, 16),
Graphic.NORMAL_ENEMY_2_HEALTH: RelatedTexture("normal_enemy.png", 16, 0, 16, 16),
Graphic.BOSS_5_HEALTH: RelatedTexture("boss.png", 16, 16, 16, 16),
Graphic.BOSS_4_HEALTH: RelatedTexture("boss.png", 0, 16, 16, 16),
Graphic.BOSS_3_HEALTH: RelatedTexture("boss.png", 32, 32, 16, 16),
Graphic.BOSS_2_HEALTH: RelatedTexture("boss.png", 16, 32, 16, 16),
Graphic.BOSS_1_HEALTH: RelatedTexture("boss.png", 0, 32, 16, 16),
# Items
Graphic.EMPTY_HEART: RelatedTexture("hearts.png", 0, 0, 16, 16),
Graphic.HEART: RelatedTexture("hearts.png", 16, 0, 16, 16),
Graphic.HALF_HEART: RelatedTexture("hearts.png", 32, 0, 16, 16),
Graphic.REMOTE_ITEM: RelatedTexture("items.png", 0, 16, 16, 16),
Graphic.CONFETTI_CANNON: RelatedTexture("items.png", 16, 16, 16, 16),
Graphic.HAMMER: RelatedTexture("items.png", 32, 16, 16, 16),
Graphic.KEY: RelatedTexture("items.png", 0, 0, 16, 16),
Graphic.SHIELD: RelatedTexture("items.png", 16, 0, 16, 16),
Graphic.SWORD: RelatedTexture("items.png", 32, 0, 16, 16),
Graphic.ITEMS_TEXT: "items_text.png",
# Numbers
Graphic.ZERO: RelatedTexture("numbers.png", 0, 16, 16, 16),
Graphic.ONE: RelatedTexture("numbers.png", 16, 16, 16, 16),
Graphic.TWO: RelatedTexture("numbers.png", 32, 16, 16, 16),
@@ -70,26 +67,29 @@ IMAGE_GRAPHICS: dict[Graphic, str | RelatedTexture] = {
Graphic.SEVEN: RelatedTexture("numbers.png", 32, 0, 16, 16),
Graphic.EIGHT: RelatedTexture("numbers.png", 48, 0, 16, 16),
Graphic.NINE: RelatedTexture("numbers.png", 64, 0, 16, 16),
# Letters
Graphic.LETTER_A: RelatedTexture("letters.png", 0, 16, 16, 16),
Graphic.LETTER_E: RelatedTexture("letters.png", 16, 16, 16, 16),
Graphic.LETTER_H: RelatedTexture("letters.png", 32, 16, 16, 16),
Graphic.LETTER_I: RelatedTexture("letters.png", 0, 0, 16, 16),
Graphic.LETTER_M: RelatedTexture("letters.png", 16, 0, 16, 16),
Graphic.LETTER_T: RelatedTexture("letters.png", 32, 0, 16, 16),
# Mathematical symbols
Graphic.DIVIDE: RelatedTexture("symbols.png", 0, 16, 16, 16),
Graphic.EQUALS: RelatedTexture("symbols.png", 16, 16, 16, 16),
Graphic.MINUS: RelatedTexture("symbols.png", 32, 16, 16, 16),
Graphic.PLUS: RelatedTexture("symbols.png", 0, 0, 16, 16),
Graphic.TIMES: RelatedTexture("symbols.png", 16, 0, 16, 16),
# Other visual-only elements
Graphic.ITEMS_TEXT: "items_text.png",
Graphic.NO: RelatedTexture("symbols.png", 32, 0, 16, 16),
Graphic.UNKNOWN: RelatedTexture("symbols.png", 32, 0, 16, 16), # Same as "No"
}
BACKGROUND_TILE = RelatedTexture("inanimates.png", 0, 32, 16, 16)
EASY_MODE_BOSS_2_HEALTH = RelatedTexture("boss.png", 16, 0, 16, 16)
class PlayerSprite(Enum):
HUMAN = 0
@@ -160,13 +160,18 @@ def get_texture_by_identifier(texture_identifier: str | RelatedTexture) -> Textu
return sub_texture
def get_texture(graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None) -> Texture | None:
def get_texture(
graphic: Graphic | Literal["Grass"], player_sprite: PlayerSprite | None = None, hard_mode: bool = False
) -> Texture | None:
if graphic == Graphic.EMPTY:
return None
if graphic == "Grass":
return get_texture_by_identifier(BACKGROUND_TILE)
if graphic == Graphic.BOSS_2_HEALTH and not hard_mode:
return get_texture_by_identifier(EASY_MODE_BOSS_2_HEALTH)
if graphic in IMAGE_GRAPHICS:
return get_texture_by_identifier(IMAGE_GRAPHICS[graphic])
+5 -2
View File
@@ -1,12 +1,12 @@
import asyncio
import pkgutil
from asyncio import Task
from collections.abc import Buffer
from pathlib import Path
from typing import cast
from kivy import Config
from kivy.core.audio import Sound, SoundLoader
from typing_extensions import Buffer
from CommonClient import logger
@@ -85,7 +85,7 @@ class SoundManager:
def ensure_config(self) -> None:
Config.adddefaultsection("APQuest")
Config.setdefault("APQuest", "volume", 50)
Config.setdefault("APQuest", "volume", 30)
self.set_volume_percentage(Config.getint("APQuest", "volume"))
async def sound_manager_loop(self) -> None:
@@ -149,6 +149,7 @@ class SoundManager:
continue
if sound_name == audio_filename:
sound.volume = self.volume_percentage / 100
sound.play()
self.update_background_music()
higher_priority_sound_is_playing = True
@@ -213,6 +214,7 @@ class SoundManager:
# It ends up feeling better if this just always continues playing quietly after being started.
# Even "fading in at a random spot" is better than restarting the song after a jingle / math trap.
if self.game_started and song.state == "stop":
song.volume = self.current_background_music_volume * self.volume_percentage / 100
song.play()
song.seek(0)
continue
@@ -228,6 +230,7 @@ class SoundManager:
if self.current_background_music_volume != 0:
if song.state == "stop":
song.volume = self.current_background_music_volume * self.volume_percentage / 100
song.play()
song.seek(0)
+17
View File
@@ -6,6 +6,11 @@
- Die [APQuest-apworld](https://github.com/NewSoupVi/Archipelago/releases),
falls diese nicht mit deiner Version von Archipelago gebündelt ist.
## Optionale Software
- [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest), zur Verwendung mit
[PopTracker](https://github.com/black-sliver/PopTracker/releases)
## Wie man spielt
Zuerst brauchst du einen Raum, mit dem du dich verbinden kannst.
@@ -41,3 +46,15 @@ Du solltest jetzt verbunden sein und kannst APQuest spielen.
Der APQuest Client kann zwischen verschiedenen Slots wechseln, ohne neugestartet werden zu müssen,
Klicke einfach den "Disconnect"-Knopf. Dann verbinde dich mit dem anderen Raum / Slot.
## Automatisches Tracken
AP Quest verfügt über einen voll funktionsfähigen, automatischen Tracker mit Karten der Spielwelt.
1. Lade [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest) und
[PopTracker](https://github.com/black-sliver/PopTracker/releases) herunter.
2. Lege das Tracker-Pack im Ordner „packs/“ deiner PopTracker-Installation ab.
3. Öffne PopTracker und lade das APQuest-Pack.
4. Für das automatische Tracking klick oben auf das „AP“-Symbol.
5. Gib die Serveradresse von Archipelago (die, mit der du deinen Client verbunden hast), den Slot-Namen und das Passwort ein.
+16
View File
@@ -6,6 +6,11 @@
- [The APQuest apworld](https://github.com/NewSoupVi/Archipelago/releases),
if not bundled with your version of Archipelago
## Optional Software
- [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases)
## How to play
First, you need a room to connect to. For this, you or someone you know has to generate a game.
@@ -40,3 +45,14 @@ You should now be connected and able to play APQuest.
The APQuest Client can seamlessly switch rooms without restarting.
Simply click the "Disconnect" button, then connect to a different slot/room.
## Auto-Tracking
AP Quest has a fully functional map tracker that supports auto-tracking.
1. Download [APQuest AP Tracker](https://github.com/palex00/ap-quest-tracker/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into packs/ in your PopTracker install.
3. Open PopTracker, and load the APQuest pack.
4. For autotracking, click on the "AP" symbol at the top.
5. Enter the Archipelago server address (the one you connected your client to), slot name, and password.
+43 -22
View File
@@ -17,8 +17,10 @@ class Entity:
class InteractableMixin:
auto_move_attempt_passing_through = False
@abstractmethod
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
pass
@@ -89,15 +91,16 @@ class Chest(Entity, InteractableMixin, LocationMixin):
self.is_open = True
self.update_solidity()
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.has_given_content:
return
return False
if self.is_open:
self.give_content(player)
return
return True
self.open()
return True
def content_success(self) -> None:
self.update_solidity()
@@ -135,47 +138,59 @@ class Door(Entity):
class KeyDoor(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.KEY_DOOR
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.is_open:
return
return False
if not player.has_item(Item.KEY):
return
return False
player.remove_item(Item.KEY)
self.open()
return True
class BreakableBlock(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.BREAKABLE_BLOCK
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.is_open:
return
return False
if not player.has_item(Item.HAMMER):
return
return False
player.remove_item(Item.HAMMER)
self.open()
return True
class Bush(Door, InteractableMixin):
auto_move_attempt_passing_through = True
closed_graphic = Graphic.BUSH
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.is_open:
return
return False
if not player.has_item(Item.SWORD):
return
return False
self.open()
return True
class Button(Entity, InteractableMixin):
solid = True
@@ -186,12 +201,13 @@ class Button(Entity, InteractableMixin):
def __init__(self, activates: ActivatableMixin) -> None:
self.activates = activates
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.activated:
return
return False
self.activated = True
self.activates.activate(player)
return True
@property
def graphic(self) -> Graphic:
@@ -240,9 +256,9 @@ class Enemy(Entity, InteractableMixin):
return
self.current_health = self.max_health
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.dead:
return
return False
if player.has_item(Item.SWORD):
self.current_health = max(0, self.current_health - 1)
@@ -250,9 +266,10 @@ class Enemy(Entity, InteractableMixin):
if self.current_health == 0:
if not self.dead:
self.die()
return
return True
player.damage(2)
return True
@property
def graphic(self) -> Graphic:
@@ -270,13 +287,15 @@ class EnemyWithLoot(Enemy, LocationMixin):
self.dead = True
self.solid = not self.has_given_content
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
if self.dead:
if not self.has_given_content:
self.give_content(player)
return
return True
return False
super().interact(player)
return True
@property
def graphic(self) -> Graphic:
@@ -303,10 +322,12 @@ class FinalBoss(Enemy):
}
enemy_default_graphic = Graphic.BOSS_1_HEALTH
def interact(self, player: Player) -> None:
def interact(self, player: Player) -> bool:
dead_before = self.dead
super().interact(player)
changed = super().interact(player)
if not dead_before and self.dead:
player.victory()
return changed
+74 -10
View File
@@ -23,6 +23,8 @@ class Game:
active_math_problem: MathProblem | None
active_math_problem_input: list[int] | None
auto_target_path: list[tuple[int, int]] = []
remotely_received_items: set[tuple[int, int, int]]
def __init__(
@@ -32,6 +34,7 @@ class Game:
self.gameboard = create_gameboard(hard_mode, hammer_exists, extra_chest)
self.player = Player(self.gameboard, self.queued_events.append)
self.active_math_problem = None
self.active_math_problem_input = None
self.remotely_received_items = set()
if random_object is None:
@@ -94,29 +97,40 @@ class Game:
return tuple(graphics_array)
def attempt_player_movement(self, direction: Direction) -> None:
def attempt_player_movement(self, direction: Direction, cancel_auto_move: bool = True) -> bool:
if cancel_auto_move:
self.cancel_auto_move()
self.player.facing = direction
delta_x, delta_y = direction.value
new_x, new_y = self.player.current_x + delta_x, self.player.current_y + delta_y
if not self.gameboard.get_entity_at(new_x, new_y).solid:
self.player.current_x = new_x
self.player.current_y = new_y
if self.gameboard.get_entity_at(new_x, new_y).solid:
return False
def attempt_interact(self) -> None:
self.player.current_x = new_x
self.player.current_y = new_y
return True
def attempt_interact(self) -> bool:
delta_x, delta_y = self.player.facing.value
entity_x, entity_y = self.player.current_x + delta_x, self.player.current_y + delta_y
entity = self.gameboard.get_entity_at(entity_x, entity_y)
if isinstance(entity, InteractableMixin):
entity.interact(self.player)
return entity.interact(self.player)
def attempt_fire_confetti_cannon(self) -> None:
if self.player.has_item(Item.CONFETTI_CANNON):
self.player.remove_item(Item.CONFETTI_CANNON)
self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y))
return False
def attempt_fire_confetti_cannon(self) -> bool:
if not self.player.has_item(Item.CONFETTI_CANNON):
return False
self.player.remove_item(Item.CONFETTI_CANNON)
self.queued_events.append(ConfettiFired(self.player.current_x, self.player.current_y))
return True
def math_problem_success(self) -> None:
self.active_math_problem = None
@@ -154,6 +168,12 @@ class Game:
self.active_math_problem_input.pop()
self.check_math_problem_result()
def math_problem_replace(self, input: list[int]) -> None:
if self.active_math_problem_input is None:
return
self.active_math_problem_input = input[:2]
self.check_math_problem_result()
def input(self, input_key: Input) -> None:
if not self.gameboard.ready:
return
@@ -201,3 +221,47 @@ class Game:
def force_clear_location(self, location_id: int) -> None:
location = Location(location_id)
self.gameboard.force_clear_location(location)
def cancel_auto_move(self) -> None:
self.auto_target_path = []
def queue_auto_move(self, target_x: int, target_y: int) -> None:
self.cancel_auto_move()
path = self.gameboard.calculate_shortest_path(self.player.current_x, self.player.current_y, target_x, target_y)
self.auto_target_path = path
def do_auto_move(self) -> bool:
if not self.auto_target_path:
return False
target_x, target_y = self.auto_target_path.pop(0)
movement = target_x - self.player.current_x, target_y - self.player.current_y
direction = Direction(movement)
moved = self.attempt_player_movement(direction, cancel_auto_move=False)
if moved:
return True
# We are attempting to interact with something on the path.
# First, make the player face it.
if self.player.facing != direction:
self.player.facing = direction
self.auto_target_path.insert(0, (target_x, target_y))
return True
# If we are facing it, attempt to interact with it.
changed = self.attempt_interact()
if not changed:
self.cancel_auto_move()
return False
# If the interaction was successful, and this was the end of the path, stop
# (i.e. don't try to attack the attacked enemy over and over until it's dead)
if not self.auto_target_path:
self.cancel_auto_move()
return True
# If there is more to go, keep going along the path
self.auto_target_path.insert(0, (target_x, target_y))
return True
+35 -1
View File
@@ -15,6 +15,7 @@ from .entities import (
EnemyWithLoot,
Entity,
FinalBoss,
InteractableMixin,
KeyDoor,
LocationMixin,
Wall,
@@ -23,6 +24,7 @@ from .generate_math_problem import MathProblem
from .graphics import DIGIT_TO_GRAPHIC, DIGIT_TO_GRAPHIC_ZERO_EMPTY, MATH_PROBLEM_TYPE_TO_GRAPHIC, Graphic
from .items import Item
from .locations import DEFAULT_CONTENT, Location
from .path_finding import find_path_or_closest
if TYPE_CHECKING:
from .player import Player
@@ -107,6 +109,21 @@ class Gameboard:
return tuple(graphics)
def as_traversability_bools(self) -> tuple[tuple[bool, ...], ...]:
traversability = []
for y, row in enumerate(self.gameboard):
traversable_row = []
for x, entity in enumerate(row):
traversable_row.append(
not entity.solid
or (isinstance(entity, InteractableMixin) and entity.auto_move_attempt_passing_through)
)
traversability.append(tuple(traversable_row))
return tuple(traversability)
def render_math_problem(
self, problem: MathProblem, current_input_digits: list[int], current_input_int: int | None
) -> tuple[tuple[Graphic, ...], ...]:
@@ -186,6 +203,23 @@ class Gameboard:
entity = self.remote_entity_by_location_id[location]
entity.force_clear()
def calculate_shortest_path(
self, source_x: int, source_y: int, target_x: int, target_y: int
) -> list[tuple[int, int]]:
gameboard_traversability = self.as_traversability_bools()
path = find_path_or_closest(gameboard_traversability, source_x, source_y, target_x, target_y)
if not path:
return path
# If the path stops just short of target, attempt interacting with it at the end
if abs(path[-1][0] - target_x) + abs(path[-1][1] - target_y) == 1:
if isinstance(self.gameboard[target_y][target_x], InteractableMixin):
path.append((target_x, target_y))
return path[1:] # Cut off starting tile
@property
def ready(self) -> bool:
return self.content_filled
@@ -212,7 +246,7 @@ def create_gameboard(hard_mode: bool, hammer_exists: bool, extra_chest: bool) ->
breakable_block = BreakableBlock() if hammer_exists else Empty()
normal_enemy = EnemyWithLoot(2 if hard_mode else 1, Location.ENEMY_DROP)
boss = FinalBoss(5 if hard_mode else 3)
boss = FinalBoss(5 if hard_mode else 2)
gameboard = (
(Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty(), Wall(), Empty(), Empty(), Empty()),
@@ -6,6 +6,7 @@ from typing import NamedTuple
_random = random.Random()
class NumberChoiceConstraints(NamedTuple):
num_1_min: int
num_1_max: int
Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 754 B

+84
View File
@@ -0,0 +1,84 @@
import heapq
from collections.abc import Generator
Point = tuple[int, int]
def heuristic(a: Point, b: Point) -> int:
# Manhattan distance (good for 4-directional grids)
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def reconstruct_path(came_from: dict[Point, Point], current: Point) -> list[Point]:
path = [current]
while current in came_from:
current = came_from[current]
path.append(current)
path.reverse()
return path
def find_path_or_closest(
grid: tuple[tuple[bool, ...], ...], source_x: int, source_y: int, target_x: int, target_y: int
) -> list[Point]:
start = source_x, source_y
goal = target_x, target_y
rows, cols = len(grid), len(grid[0])
def in_bounds(p: Point) -> bool:
return 0 <= p[0] < rows and 0 <= p[1] < cols
def passable(p: Point) -> bool:
return grid[p[1]][p[0]]
def neighbors(p: Point) -> Generator[Point, None, None]:
x, y = p
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
np = (x + dx, y + dy)
if in_bounds(np) and passable(np):
yield np
open_heap: list[tuple[int, tuple[int, int]]] = []
heapq.heappush(open_heap, (0, start))
came_from: dict[Point, Point] = {}
g_score = {start: 0}
# Track best fallback node
best_node = start
best_dist = heuristic(start, goal)
visited = set()
while open_heap:
_, current = heapq.heappop(open_heap)
if current in visited:
continue
visited.add(current)
# Check if we reached the goal
if current == goal:
return reconstruct_path(came_from, current)
# Update "closest node" fallback
dist = heuristic(current, goal)
if dist < best_dist or (dist == best_dist and g_score[current] < g_score.get(best_node, float("inf"))):
best_node = current
best_dist = dist
for neighbor in neighbors(current):
tentative_g = g_score[current] + 1 # cost is 1 per move
if tentative_g < g_score.get(neighbor, float("inf")):
came_from[neighbor] = current
g_score[neighbor] = tentative_g
f_score = tentative_g + heuristic(neighbor, goal)
heapq.heappush(open_heap, (f_score, neighbor))
# Goal not reachable → return path to closest node
if best_node is not None:
return reconstruct_path(came_from, best_node)
return []
+92 -71
View File
@@ -2,12 +2,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from BaseClasses import CollectionState
from worlds.generic.Rules import add_rule, set_rule
from rule_builder.options import OptionFilter
from rule_builder.rules import Has, HasAll, Rule
from .options import HardMode
if TYPE_CHECKING:
from .world import APQuestWorld
HAS_KEY = Has("Key") # Hmm, what could this be? A little foreshadowing perhaps? :) You'll find out if you keep reading!
def set_all_rules(world: APQuestWorld) -> None:
# In order for AP to generate an item layout that is actually possible for the player to complete,
@@ -26,36 +30,46 @@ def set_all_entrance_rules(world: APQuestWorld) -> None:
overworld_to_top_left_room = world.get_entrance("Overworld to Top Left Room")
right_room_to_final_boss_room = world.get_entrance("Right Room to Final Boss Room")
# An access rule is a function. We can define this function like any other function.
# This function must accept exactly one parameter: A "CollectionState".
# A CollectionState describes the current progress of the players in the multiworld, i.e. what items they have,
# which regions they've reached, etc.
# In an access rule, we can ask whether the player has a collected a certain item.
# We can do this via the state.has(...) function.
# This function takes an item name, a player number, and an optional count parameter (more on that below)
# Since a rule only takes a CollectionState parameter, but we also need the player number in the state.has call,
# our function needs to be locally defined so that it has access to the player number from the outer scope.
# In our case, we are inside a function that has access to the "world" parameter, so we can use world.player.
def can_destroy_bush(state: CollectionState) -> bool:
return state.has("Sword", world.player)
# Now, let's make some rules!
# First, let's handle the transition from the overworld to the bottom right room,
# which requires slashing a bush with the Sword.
# For this, we need a rule that says "player has a Sword".
# We can use a "Has"-type rule from the rule_builder module for this.
can_destroy_bush = Has("Sword")
# Now we can set our "can_destroy_bush" rule to our entrance which requires slashing a bush to clear the path.
# One way to set rules is via the set_rule() function, which works on both Entrances and Locations.
set_rule(overworld_to_bottom_right_room, can_destroy_bush)
# Now we can set our "can_destroy_bush" rule to the entrance which requires slashing a bush to clear the path.
# The easiest way to do this is by calling world.set_rule, which works for both Locations and Entrances.
world.set_rule(overworld_to_bottom_right_room, can_destroy_bush)
# Because the function has to be defined locally, most worlds prefer the lambda syntax.
set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player))
# Conditions can depend on event items.
set_rule(right_room_to_final_boss_room, lambda state: state.has("Top Left Room Button Pressed", world.player))
# Conditions can also depend on event items.
button_pressed = Has("Top Left Room Button Pressed")
world.set_rule(right_room_to_final_boss_room, button_pressed)
# Some entrance rules may only apply if the player enabled certain options.
# In our case, if the hammer option is enabled, we need to add the Hammer requirement to the Entrance from
# Overworld to the Top Middle Room.
if world.options.hammer:
overworld_to_top_middle_room = world.get_entrance("Overworld to Top Middle Room")
set_rule(overworld_to_top_middle_room, lambda state: state.has("Hammer", world.player))
can_smash_brick = Has("Hammer")
world.set_rule(overworld_to_top_middle_room, can_smash_brick)
# So far, we've been using "Has" from the Rule Builder to make our rules.
# There is another way to make rules that you will see in a lot of older worlds.
# A rule can just be a function that takes a "state" argument and returns a bool.
# As a demonstration of what that looks like, let's do it with our final Entrance rule:
world.set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player))
# This style is not really recommended anymore, though.
# Notice how you have to explicitly capture world.player here so that the rule applies to the correct player?
# Well, Rule Builder does this part for you, inside of world.set_rule.
# This doesn't just result in shorter code, it also means you can define rules statically (at the module level).
# APQuest opts to create its Rule objects locally, but just to show what this would look like,
# we'll re-set the "Overworld to Top Left Room" rule to a constant defined at the top of this file:
world.set_rule(overworld_to_top_left_room, HAS_KEY)
# Beyond these structural advantages,
# Rule Builder also allows the core AP code to do a lot of under-the-hood optimizations.
# Rule Builder is quite comprehensive, and even if you have really esoteric rules,
# you can make custom rules by subclassing CustomRule.
def set_all_location_rules(world: APQuestWorld) -> None:
# Location rules work no differently from Entrance rules.
@@ -67,65 +81,72 @@ def set_all_location_rules(world: APQuestWorld) -> None:
# So, we need to set requirements on the Locations themselves.
# Since combat is a bit more complicated, we'll use this chance to cover some advanced access rule concepts.
# Sometimes, you may want to have different rules depending on the player's chosen options.
# There is a wrong way to do this, and a right way to do this. Let's do the wrong way first.
# In "set_all_entrance_rules", we had a rule for a location that doesn't always exist.
# In this case, we had to check for its existence (by checking the player's chosen options) before setting the rule.
# Other times, you may have a situation where a location can have two different rules depending on the options.
# In our case, the enemy in the right room has more health if hard mode is selected,
# so ontop of the Sword, the player will either need one more health or a Shield in hard mode.
# First, let's make our sword condition.
can_defeat_basic_enemy: Rule = Has("Sword")
# Next, we'll check whether hard mode has been chosen in the player options.
if world.options.hard_mode:
# We'll make the condition for "Has a Shield or a Health Upgrade".
# We can chain two "Has" conditions together with the | operator to make "Has Shield or has Health Upgrade".
can_withstand_a_hit = Has("Shield") | Has("Health Upgrade")
# Now, we chain this rule to our Sword rule.
# Since we want both conditions to be true, in this case, we have to chain them in an "and" way.
# For this, we can use the & operator.
can_defeat_basic_enemy = can_defeat_basic_enemy & can_withstand_a_hit
# Finally, we set our rule onto the Right Room Eney Drop location.
right_room_enemy = world.get_location("Right Room Enemy Drop")
world.set_rule(right_room_enemy, can_defeat_basic_enemy)
# DON'T DO THIS!!!!
set_rule(
right_room_enemy,
lambda state: (
state.has("Sword", world.player)
and (not world.options.hard_mode or state.has_any(("Shield", "Health Upgrade"), world.player))
),
)
# DON'T DO THIS!!!!
# For the final boss, we also need to chain multiple conditions.
# First of all, you always need a Sword and a Shield.
# So far, we used the | and & operators to chain "Has" rules.
# Instead, we can also use HasAny for an or-chain of items, or HasAll for an and-chain of items.
has_sword_and_shield: Rule = HasAll("Sword", "Shield")
# Now, what's actually wrong with this? It works perfectly fine, right?
# If hard mode disabled, Sword is enough. If hard mode is enabled, we also need a Shield or a Health Upgrade.
# The access rule we just wrote does this correctly, so what's the problem?
# The problem is performance.
# Most of your world code doesn't need to be perfectly performant, since it just runs once per slot.
# However, access rules in particular are by far the hottest code path in Archipelago.
# An access rule will potentially be called thousands or even millions of times over the course of one generation.
# As a result, access rules are the one place where it's really worth putting in some effort to optimize.
# What's the performance problem here?
# Every time our access rule is called, it has to evaluate whether world.options.hard_mode is True or False.
# Wouldn't it be better if in easy mode, the access rule only checked for Sword to begin with?
# Wouldn't it also be better if in hard mode, it already knew it had to check Shield and Health Upgrade as well?
# Well, we can achieve this by doing the "if world.options.hard_mode" check outside the set_rule call,
# and instead having two *different* set_rule calls depending on which case we're in.
# In hard mode, the player also needs both Health Upgrades to survive long enough to defeat the boss.
# For this, we can use the optional "count" parameter for "Has".
has_both_health_upgrades = Has("Health Upgrade", count=2)
if world.options.hard_mode:
# If you have multiple conditions, you can obviously chain them via "or" or "and".
# However, there are also the nice helper functions "state.has_any" and "state.has_all".
set_rule(
right_room_enemy,
lambda state: (
state.has("Sword", world.player) and state.has_any(("Shield", "Health Upgrade"), world.player)
),
)
else:
set_rule(right_room_enemy, lambda state: state.has("Sword", world.player))
# Previously, we used an "if world.options.hard_mode" condition to check if we should apply the extra requirement.
# However, if you're comfortable with boolean logic, there is another way.
# OptionFilter is a rule component which isn't a "Rule" on its own, but when used in a boolean expression with
# rules, it acts like True if the option has the specified value, and acts like False otherwise.
hard_mode_is_off = OptionFilter(HardMode, False)
# Another way to chain multiple conditions is via the add_rule function.
# This makes the access rules a bit slower though, so it should only be used if your structure justifies it.
# In our case, it's pretty useful because hard mode and easy mode have different requirements.
# So with this option-checking rule component in hand, we can write our boss condition like this:
can_defeat_final_boss = has_sword_and_shield & (hard_mode_is_off | has_both_health_upgrades)
# If you're not as comfortable with boolean logic, it might be somewhat confusing why this is correct.
# There is nothing wrong with using "if" conditions to check for options, if you find that easier to understand.
# Finally, we apply the rule to our "Final Boss Defeated" event location.
final_boss = world.get_location("Final Boss Defeated")
# For the "known" requirements, it's still better to chain them using a normal "and" condition.
add_rule(final_boss, lambda state: state.has_all(("Sword", "Shield"), world.player))
if world.options.hard_mode:
# You can check for multiple copies of an item by using the optional count parameter of state.has().
add_rule(final_boss, lambda state: state.has("Health Upgrade", world.player, 2))
world.set_rule(final_boss, can_defeat_final_boss)
def set_completion_condition(world: APQuestWorld) -> None:
# Finally, we need to set a completion condition for our world, defining what the player needs to win the game.
# For this, we can use world.set_completion_rule.
# You can just set a completion condition directly like any other condition, referencing items the player receives:
world.multiworld.completion_condition[world.player] = lambda state: state.has_all(("Sword", "Shield"), world.player)
world.set_completion_rule(HasAll("Sword", "Shield"))
# In our case, we went for the Victory event design pattern (see create_events() in locations.py).
# So lets undo what we just did, and instead set the completion condition to:
world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player)
world.set_completion_rule(Has("Victory"))
# One final comment about rules:
# If your world exclusively uses Rule Builder rules (like APQuest), it's worth trying CachedRuleBuilderWorld.
# CachedRuleBuilderWorld is a subclass of World that has a bunch of caching magic to make rules faster.
# Just have your world class subclass CachedRuleBuilderWorld instead of World:
# class APQuestWorld(CachedRuleBuilderWorld): ...
# This may speed up your world, or it may make it slower.
# The exact factors are complex and not well understood, but there is no harm in trying it.
# Generate a few seeds and see if there is a noticeable difference!
# If you're wondering, author has checked: APQuest is too simple to see any benefits, so we'll stick with "World".
-34
View File
@@ -1,34 +0,0 @@
from typing import Dict
from BaseClasses import Tutorial
from ..AutoWorld import WebWorld, World
class AP_SudokuWebWorld(WebWorld):
options_page = False
theme = 'partyTime'
setup_en = Tutorial(
tutorial_name='Setup Guide',
description='A guide to playing APSudoku',
language='English',
file_name='setup_en.md',
link='setup/en',
authors=['EmilyV']
)
tutorials = [setup_en]
class AP_SudokuWorld(World):
"""
Play a little Sudoku while you're in BK mode to maybe get some useful hints
"""
game = "Sudoku"
web = AP_SudokuWebWorld()
item_name_to_id: Dict[str, int] = {}
location_name_to_id: Dict[str, int] = {}
@classmethod
def stage_assert_generate(cls, multiworld):
raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")
-15
View File
@@ -1,15 +0,0 @@
# APSudoku
## Hint Games
HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot.
## What is this game?
Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random.
## Where is the options page?
There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room. This allows disabling hints entirely, as well as altering the hint odds for each difficulty.
-55
View File
@@ -1,55 +0,0 @@
# APSudoku Setup Guide
## Required Software
- [APSudoku](https://github.com/APSudoku/APSudoku)
## General Concept
This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
## Installation Procedures
### Windows / Linux
Go to the latest release from the [github APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
### Web
Go to the [github pages](apsudoku.github.io) or [itch.io](https://emilyv99.itch.io/apsudoku) site, and play in the browser.
## Joining a MultiWorld Game
1. Run the APSudoku executable.
2. Under `Settings` &rarr; `Connection` at the top-right:
- Enter the server address and port number
- Enter the name of the slot you wish to connect to
- Enter the room password (optional)
- Select DeathLink related settings (optional)
- Press `Connect`
4. Under the `Sudoku` tab
- Choose puzzle difficulty
- Click `Start` to generate a puzzle
5. Try to solve the Sudoku. Click `Check` when done
- A correct solution rewards you with 1 hint for a location in the world you are connected to
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
Info:
- You can set various settings under `Settings` &rarr; `Sudoku`, and can change the colors used under `Settings` &rarr; `Theme`.
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
- Click the various `?` buttons for information on controls/how to play
## Admin Settings
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room.
- You can disable APSudoku for the entire room, preventing any hints from being granted.
- You can customize the reward weights for each difficulty, making progression hints more or less likely, and/or adding a chance to get "no hint" after a solve.
## DeathLink Support
If `DeathLink` is enabled when you click `Connect`:
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
- On receiving a DeathLink from another player, your puzzle resets.
+3 -3
View File
@@ -271,7 +271,7 @@ item_table = {
ItemNames.TRIDENT: ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head
ItemNames.TURTLE_EGG: ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg
ItemNames.JELLY_EGG: ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed
ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume
ItemNames.URCHIN_COSTUME: ItemData(698034, 1, ItemType.PROGRESSION, ItemGroup.COLLECTIBLE), # collectible_urchin_costume
ItemNames.BABY_WALKER: ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker
ItemNames.VEDHA_S_CURE_ALL: ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All
ItemNames.ZUUNA_S_PEROGI: ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi
@@ -384,8 +384,8 @@ four_gods_excludes = [ItemNames.ANEMONE, ItemNames.ARNASSI_STATUE, ItemNames.BIG
ItemNames.MITHALAS_BANNER, ItemNames.MITHALAS_POT, ItemNames.MUTANT_COSTUME, ItemNames.SEED_BAG,
ItemNames.KING_S_SKULL, ItemNames.SONG_PLANT_SPORE, ItemNames.STONE_HEAD, ItemNames.SUN_KEY,
ItemNames.GIRL_COSTUME, ItemNames.ODD_CONTAINER, ItemNames.TRIDENT, ItemNames.TURTLE_EGG,
ItemNames.JELLY_EGG, ItemNames.URCHIN_COSTUME, ItemNames.BABY_WALKER,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM,
ItemNames.JELLY_EGG, ItemNames.BABY_WALKER, ItemNames.RAINBOW_MUSHROOM,
ItemNames.RAINBOW_MUSHROOM, ItemNames.RAINBOW_MUSHROOM, ItemNames.FISH_OIL,
ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE, ItemNames.LEAF_POULTICE,
ItemNames.LEECHING_POULTICE, ItemNames.LEECHING_POULTICE, ItemNames.ARCANE_POULTICE,
ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT, ItemNames.ROTTEN_MEAT,
+1 -1
View File
@@ -37,7 +37,7 @@ def _has_li(state: CollectionState, player: int) -> bool:
DAMAGING_ITEMS:Iterable[str] = [
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME
]
def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool:
+1 -1
View File
@@ -76,7 +76,7 @@ class AquariaWorld(World):
item_name_groups = {
"Damage": {ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER},
ItemNames.BABY_BLASTER, ItemNames.URCHIN_COSTUME},
"Light": {ItemNames.SUN_FORM, ItemNames.BABY_DUMBO}
}
"""Grouping item make it easier to find them"""
+1 -1
View File
@@ -116,7 +116,7 @@ def versum_hill_rave(state: CollectionState, player: int, limit: bool, glitched:
else:
return (
graffitiL(state, player, limit, 85)
and graffitiXL(state, player, limit, 48)
and graffitiXL(state, player, limit, 49)
)
else:
return (
@@ -0,0 +1,5 @@
{
"game": "Bomb Rush Cyberfunk",
"world_version": "1.0.6",
"authors": ["TRPG"]
}
+105 -105
View File
@@ -16,213 +16,213 @@ from .Locations import (
)
def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) -> None:
menu_region = Region("Menu", player, world, "Aranearum")
world.regions.append(menu_region)
def create_regions(multiworld: MultiWorld, options: CCCharlesOptions, player: int) -> None:
menu_region = Region("Menu", player, multiworld, "Aranearum")
multiworld.regions.append(menu_region)
start_camp_region = Region("Start Camp", player, world)
start_camp_region = Region("Start Camp", player, multiworld)
start_camp_region.add_locations(loc_start_camp, CCCharlesLocation)
world.regions.append(start_camp_region)
multiworld.regions.append(start_camp_region)
tony_tiddle_mission_region = Region("Tony Tiddle Mission", player, world)
tony_tiddle_mission_region = Region("Tony Tiddle Mission", player, multiworld)
tony_tiddle_mission_region.add_locations(loc_tony_tiddle_mission, CCCharlesLocation)
world.regions.append(tony_tiddle_mission_region)
multiworld.regions.append(tony_tiddle_mission_region)
barn_region = Region("Barn", player, world)
barn_region = Region("Barn", player, multiworld)
barn_region.add_locations(loc_barn, CCCharlesLocation)
world.regions.append(barn_region)
multiworld.regions.append(barn_region)
candice_mission_region = Region("Candice Mission", player, world)
candice_mission_region = Region("Candice Mission", player, multiworld)
candice_mission_region.add_locations(loc_candice_mission, CCCharlesLocation)
world.regions.append(candice_mission_region)
multiworld.regions.append(candice_mission_region)
tutorial_house_region = Region("Tutorial House", player, world)
tutorial_house_region = Region("Tutorial House", player, multiworld)
tutorial_house_region.add_locations(loc_tutorial_house, CCCharlesLocation)
world.regions.append(tutorial_house_region)
multiworld.regions.append(tutorial_house_region)
swamp_edges_region = Region("Swamp Edges", player, world)
swamp_edges_region = Region("Swamp Edges", player, multiworld)
swamp_edges_region.add_locations(loc_swamp_edges, CCCharlesLocation)
world.regions.append(swamp_edges_region)
multiworld.regions.append(swamp_edges_region)
swamp_mission_region = Region("Swamp Mission", player, world)
swamp_mission_region = Region("Swamp Mission", player, multiworld)
swamp_mission_region.add_locations(loc_swamp_mission, CCCharlesLocation)
world.regions.append(swamp_mission_region)
multiworld.regions.append(swamp_mission_region)
junkyard_area_region = Region("Junkyard Area", player, world)
junkyard_area_region = Region("Junkyard Area", player, multiworld)
junkyard_area_region.add_locations(loc_junkyard_area, CCCharlesLocation)
world.regions.append(junkyard_area_region)
multiworld.regions.append(junkyard_area_region)
south_house_region = Region("South House", player, world)
south_house_region = Region("South House", player, multiworld)
south_house_region.add_locations(loc_south_house, CCCharlesLocation)
world.regions.append(south_house_region)
multiworld.regions.append(south_house_region)
junkyard_shed_region = Region("Junkyard Shed", player, world)
junkyard_shed_region = Region("Junkyard Shed", player, multiworld)
junkyard_shed_region.add_locations(loc_junkyard_shed, CCCharlesLocation)
world.regions.append(junkyard_shed_region)
multiworld.regions.append(junkyard_shed_region)
military_base_region = Region("Military Base", player, world)
military_base_region = Region("Military Base", player, multiworld)
military_base_region.add_locations(loc_military_base, CCCharlesLocation)
world.regions.append(military_base_region)
multiworld.regions.append(military_base_region)
south_mine_outside_region = Region("South Mine Outside", player, world)
south_mine_outside_region = Region("South Mine Outside", player, multiworld)
south_mine_outside_region.add_locations(loc_south_mine_outside, CCCharlesLocation)
world.regions.append(south_mine_outside_region)
multiworld.regions.append(south_mine_outside_region)
south_mine_inside_region = Region("South Mine Inside", player, world)
south_mine_inside_region = Region("South Mine Inside", player, multiworld)
south_mine_inside_region.add_locations(loc_south_mine_inside, CCCharlesLocation)
world.regions.append(south_mine_inside_region)
multiworld.regions.append(south_mine_inside_region)
middle_station_region = Region("Middle Station", player, world)
middle_station_region = Region("Middle Station", player, multiworld)
middle_station_region.add_locations(loc_middle_station, CCCharlesLocation)
world.regions.append(middle_station_region)
multiworld.regions.append(middle_station_region)
canyon_region = Region("Canyon", player, world)
canyon_region = Region("Canyon", player, multiworld)
canyon_region.add_locations(loc_canyon, CCCharlesLocation)
world.regions.append(canyon_region)
multiworld.regions.append(canyon_region)
watchtower_region = Region("Watchtower", player, world)
watchtower_region = Region("Watchtower", player, multiworld)
watchtower_region.add_locations(loc_watchtower, CCCharlesLocation)
world.regions.append(watchtower_region)
multiworld.regions.append(watchtower_region)
boulder_field_region = Region("Boulder Field", player, world)
boulder_field_region = Region("Boulder Field", player, multiworld)
boulder_field_region.add_locations(loc_boulder_field, CCCharlesLocation)
world.regions.append(boulder_field_region)
multiworld.regions.append(boulder_field_region)
haunted_house_region = Region("Haunted House", player, world)
haunted_house_region = Region("Haunted House", player, multiworld)
haunted_house_region.add_locations(loc_haunted_house, CCCharlesLocation)
world.regions.append(haunted_house_region)
multiworld.regions.append(haunted_house_region)
santiago_house_region = Region("Santiago House", player, world)
santiago_house_region = Region("Santiago House", player, multiworld)
santiago_house_region.add_locations(loc_santiago_house, CCCharlesLocation)
world.regions.append(santiago_house_region)
multiworld.regions.append(santiago_house_region)
port_region = Region("Port", player, world)
port_region = Region("Port", player, multiworld)
port_region.add_locations(loc_port, CCCharlesLocation)
world.regions.append(port_region)
multiworld.regions.append(port_region)
trench_house_region = Region("Trench House", player, world)
trench_house_region = Region("Trench House", player, multiworld)
trench_house_region.add_locations(loc_trench_house, CCCharlesLocation)
world.regions.append(trench_house_region)
multiworld.regions.append(trench_house_region)
doll_woods_region = Region("Doll Woods", player, world)
doll_woods_region = Region("Doll Woods", player, multiworld)
doll_woods_region.add_locations(loc_doll_woods, CCCharlesLocation)
world.regions.append(doll_woods_region)
multiworld.regions.append(doll_woods_region)
lost_stairs_region = Region("Lost Stairs", player, world)
lost_stairs_region = Region("Lost Stairs", player, multiworld)
lost_stairs_region.add_locations(loc_lost_stairs, CCCharlesLocation)
world.regions.append(lost_stairs_region)
multiworld.regions.append(lost_stairs_region)
east_house_region = Region("East House", player, world)
east_house_region = Region("East House", player, multiworld)
east_house_region.add_locations(loc_east_house, CCCharlesLocation)
world.regions.append(east_house_region)
multiworld.regions.append(east_house_region)
rockets_testing_ground_region = Region("Rockets Testing Ground", player, world)
rockets_testing_ground_region = Region("Rockets Testing Ground", player, multiworld)
rockets_testing_ground_region.add_locations(loc_rockets_testing_ground, CCCharlesLocation)
world.regions.append(rockets_testing_ground_region)
multiworld.regions.append(rockets_testing_ground_region)
rockets_testing_bunker_region = Region("Rockets Testing Bunker", player, world)
rockets_testing_bunker_region = Region("Rockets Testing Bunker", player, multiworld)
rockets_testing_bunker_region.add_locations(loc_rockets_testing_bunker, CCCharlesLocation)
world.regions.append(rockets_testing_bunker_region)
multiworld.regions.append(rockets_testing_bunker_region)
workshop_region = Region("Workshop", player, world)
workshop_region = Region("Workshop", player, multiworld)
workshop_region.add_locations(loc_workshop, CCCharlesLocation)
world.regions.append(workshop_region)
multiworld.regions.append(workshop_region)
east_tower_region = Region("East Tower", player, world)
east_tower_region = Region("East Tower", player, multiworld)
east_tower_region.add_locations(loc_east_tower, CCCharlesLocation)
world.regions.append(east_tower_region)
multiworld.regions.append(east_tower_region)
lighthouse_region = Region("Lighthouse", player, world)
lighthouse_region = Region("Lighthouse", player, multiworld)
lighthouse_region.add_locations(loc_lighthouse, CCCharlesLocation)
world.regions.append(lighthouse_region)
multiworld.regions.append(lighthouse_region)
north_mine_outside_region = Region("North Mine Outside", player, world)
north_mine_outside_region = Region("North Mine Outside", player, multiworld)
north_mine_outside_region.add_locations(loc_north_mine_outside, CCCharlesLocation)
world.regions.append(north_mine_outside_region)
multiworld.regions.append(north_mine_outside_region)
north_mine_inside_region = Region("North Mine Inside", player, world)
north_mine_inside_region = Region("North Mine Inside", player, multiworld)
north_mine_inside_region.add_locations(loc_north_mine_inside, CCCharlesLocation)
world.regions.append(north_mine_inside_region)
multiworld.regions.append(north_mine_inside_region)
wood_bridge_region = Region("Wood Bridge", player, world)
wood_bridge_region = Region("Wood Bridge", player, multiworld)
wood_bridge_region.add_locations(loc_wood_bridge, CCCharlesLocation)
world.regions.append(wood_bridge_region)
multiworld.regions.append(wood_bridge_region)
museum_region = Region("Museum", player, world)
museum_region = Region("Museum", player, multiworld)
museum_region.add_locations(loc_museum, CCCharlesLocation)
world.regions.append(museum_region)
multiworld.regions.append(museum_region)
barbed_shelter_region = Region("Barbed Shelter", player, world)
barbed_shelter_region = Region("Barbed Shelter", player, multiworld)
barbed_shelter_region.add_locations(loc_barbed_shelter, CCCharlesLocation)
world.regions.append(barbed_shelter_region)
multiworld.regions.append(barbed_shelter_region)
west_beach_region = Region("West Beach", player, world)
west_beach_region = Region("West Beach", player, multiworld)
west_beach_region.add_locations(loc_west_beach, CCCharlesLocation)
world.regions.append(west_beach_region)
multiworld.regions.append(west_beach_region)
church_region = Region("Church", player, world)
church_region = Region("Church", player, multiworld)
church_region.add_locations(loc_church, CCCharlesLocation)
world.regions.append(church_region)
multiworld.regions.append(church_region)
west_cottage_region = Region("West Cottage", player, world)
west_cottage_region = Region("West Cottage", player, multiworld)
west_cottage_region.add_locations(loc_west_cottage, CCCharlesLocation)
world.regions.append(west_cottage_region)
multiworld.regions.append(west_cottage_region)
caravan_region = Region("Caravan", player, world)
caravan_region = Region("Caravan", player, multiworld)
caravan_region.add_locations(loc_caravan, CCCharlesLocation)
world.regions.append(caravan_region)
multiworld.regions.append(caravan_region)
trailer_cabin_region = Region("Trailer Cabin", player, world)
trailer_cabin_region = Region("Trailer Cabin", player, multiworld)
trailer_cabin_region.add_locations(loc_trailer_cabin, CCCharlesLocation)
world.regions.append(trailer_cabin_region)
multiworld.regions.append(trailer_cabin_region)
towers_region = Region("Towers", player, world)
towers_region = Region("Towers", player, multiworld)
towers_region.add_locations(loc_towers, CCCharlesLocation)
world.regions.append(towers_region)
multiworld.regions.append(towers_region)
north_beach_region = Region("North beach", player, world)
north_beach_region = Region("North beach", player, multiworld)
north_beach_region.add_locations(loc_north_beach, CCCharlesLocation)
world.regions.append(north_beach_region)
multiworld.regions.append(north_beach_region)
mine_shaft_region = Region("Mine Shaft", player, world)
mine_shaft_region = Region("Mine Shaft", player, multiworld)
mine_shaft_region.add_locations(loc_mine_shaft, CCCharlesLocation)
world.regions.append(mine_shaft_region)
multiworld.regions.append(mine_shaft_region)
mob_camp_region = Region("Mob Camp", player, world)
mob_camp_region = Region("Mob Camp", player, multiworld)
mob_camp_region.add_locations(loc_mob_camp, CCCharlesLocation)
world.regions.append(mob_camp_region)
multiworld.regions.append(mob_camp_region)
mob_camp_locked_room_region = Region("Mob Camp Locked Room", player, world)
mob_camp_locked_room_region = Region("Mob Camp Locked Room", player, multiworld)
mob_camp_locked_room_region.add_locations(loc_mob_camp_locked_room, CCCharlesLocation)
world.regions.append(mob_camp_locked_room_region)
multiworld.regions.append(mob_camp_locked_room_region)
mine_elevator_exit_region = Region("Mine Elevator Exit", player, world)
mine_elevator_exit_region = Region("Mine Elevator Exit", player, multiworld)
mine_elevator_exit_region.add_locations(loc_mine_elevator_exit, CCCharlesLocation)
world.regions.append(mine_elevator_exit_region)
multiworld.regions.append(mine_elevator_exit_region)
mountain_ruin_outside_region = Region("Mountain Ruin Outside", player, world)
mountain_ruin_outside_region = Region("Mountain Ruin Outside", player, multiworld)
mountain_ruin_outside_region.add_locations(loc_mountain_ruin_outside, CCCharlesLocation)
world.regions.append(mountain_ruin_outside_region)
multiworld.regions.append(mountain_ruin_outside_region)
mountain_ruin_inside_region = Region("Mountain Ruin Inside", player, world)
mountain_ruin_inside_region = Region("Mountain Ruin Inside", player, multiworld)
mountain_ruin_inside_region.add_locations(loc_mountain_ruin_inside, CCCharlesLocation)
world.regions.append(mountain_ruin_inside_region)
multiworld.regions.append(mountain_ruin_inside_region)
prism_temple_region = Region("Prism Temple", player, world)
prism_temple_region = Region("Prism Temple", player, multiworld)
prism_temple_region.add_locations(loc_prism_temple, CCCharlesLocation)
world.regions.append(prism_temple_region)
multiworld.regions.append(prism_temple_region)
pickle_val_region = Region("Pickle Val", player, world)
pickle_val_region = Region("Pickle Val", player, multiworld)
pickle_val_region.add_locations(loc_pickle_val, CCCharlesLocation)
world.regions.append(pickle_val_region)
multiworld.regions.append(pickle_val_region)
shrine_near_temple_region = Region("Shrine Near Temple", player, world)
shrine_near_temple_region = Region("Shrine Near Temple", player, multiworld)
shrine_near_temple_region.add_locations(loc_shrine_near_temple, CCCharlesLocation)
world.regions.append(shrine_near_temple_region)
multiworld.regions.append(shrine_near_temple_region)
morse_bunker_region = Region("Morse Bunker", player, world)
morse_bunker_region = Region("Morse Bunker", player, multiworld)
morse_bunker_region.add_locations(loc_morse_bunker, CCCharlesLocation)
world.regions.append(morse_bunker_region)
multiworld.regions.append(morse_bunker_region)
# Place "Victory" event at "Final Boss" location
loc_final_boss = CCCharlesLocation(player, "Final Boss", None, prism_temple_region)
+74 -74
View File
@@ -4,212 +4,212 @@ from .Options import CCCharlesOptions
# Go mode: Green Egg + Blue Egg + Red Egg + Temple Key + Bug Spray (+ Remote Explosive x8 but the base game ignores it)
def set_rules(world: MultiWorld, options: CCCharlesOptions, player: int) -> None:
def set_rules(multiworld: MultiWorld, options: CCCharlesOptions, player: int) -> None:
# Tony Tiddle
set_rule(world.get_entrance("Barn Door", player),
set_rule(multiworld.get_entrance("Barn Door", player),
lambda state: state.has("Barn Key", player))
# Candice
set_rule(world.get_entrance("Tutorial House Door", player),
set_rule(multiworld.get_entrance("Tutorial House Door", player),
lambda state: state.has("Candice's Key", player))
# Lizbeth Murkwater
set_rule(world.get_location("Swamp Lizbeth Murkwater Mission End", player),
set_rule(multiworld.get_location("Swamp Lizbeth Murkwater Mission End", player),
lambda state: state.has("Dead Fish", player))
# Daryl
set_rule(world.get_location("Junkyard Area Chest Ancient Tablet", player),
set_rule(multiworld.get_location("Junkyard Area Chest Ancient Tablet", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Junkyard Area Daryl Mission End", player),
set_rule(multiworld.get_location("Junkyard Area Daryl Mission End", player),
lambda state: state.has("Ancient Tablet", player))
# South House
set_rule(world.get_location("South House Chest Scraps 1", player),
set_rule(multiworld.get_location("South House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("South House Chest Scraps 2", player),
set_rule(multiworld.get_location("South House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("South House Chest Scraps 3", player),
set_rule(multiworld.get_location("South House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("South House Chest Scraps 4", player),
set_rule(multiworld.get_location("South House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("South House Chest Scraps 5", player),
set_rule(multiworld.get_location("South House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("South House Chest Scraps 6", player),
set_rule(multiworld.get_location("South House Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# South Mine
set_rule(world.get_entrance("South Mine Gate", player),
set_rule(multiworld.get_entrance("South Mine Gate", player),
lambda state: state.has("South Mine Key", player))
set_rule(world.get_location("South Mine Inside Green Paint Can", player),
set_rule(multiworld.get_location("South Mine Inside Green Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Theodore
set_rule(world.get_location("Middle Station Theodore Mission End", player),
set_rule(multiworld.get_location("Middle Station Theodore Mission End", player),
lambda state: state.has("Blue Box", player))
# Watchtower
set_rule(world.get_location("Watchtower Pink Paint Can", player),
set_rule(multiworld.get_location("Watchtower Pink Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Sasha
set_rule(world.get_location("Haunted House Sasha Mission End", player),
set_rule(multiworld.get_location("Haunted House Sasha Mission End", player),
lambda state: state.has("Page Drawing", player, 8))
# Santiago
set_rule(world.get_location("Port Santiago Mission End", player),
set_rule(multiworld.get_location("Port Santiago Mission End", player),
lambda state: state.has("Journal", player))
# Trench House
set_rule(world.get_location("Trench House Chest Scraps 1", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Trench House Chest Scraps 2", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Trench House Chest Scraps 3", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Trench House Chest Scraps 4", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Trench House Chest Scraps 5", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Trench House Chest Scraps 6", player),
set_rule(multiworld.get_location("Trench House Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# East House
set_rule(world.get_location("East House Chest Scraps 1", player),
set_rule(multiworld.get_location("East House Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("East House Chest Scraps 2", player),
set_rule(multiworld.get_location("East House Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("East House Chest Scraps 3", player),
set_rule(multiworld.get_location("East House Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("East House Chest Scraps 4", player),
set_rule(multiworld.get_location("East House Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("East House Chest Scraps 5", player),
set_rule(multiworld.get_location("East House Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Rocket Testing Bunker
set_rule(world.get_entrance("Stuck Bunker Door", player),
set_rule(multiworld.get_entrance("Stuck Bunker Door", player),
lambda state: state.has("Timed Dynamite", player))
# John Smith
set_rule(world.get_location("Workshop John Smith Mission End", player),
set_rule(multiworld.get_location("Workshop John Smith Mission End", player),
lambda state: state.has("Box of Rockets", player))
# Claire
set_rule(world.get_location("Lighthouse Claire Mission End", player),
set_rule(multiworld.get_location("Lighthouse Claire Mission End", player),
lambda state: state.has("Breaker", player, 4))
# North Mine
set_rule(world.get_entrance("North Mine Gate", player),
set_rule(multiworld.get_entrance("North Mine Gate", player),
lambda state: state.has("North Mine Key", player))
set_rule(world.get_location("North Mine Inside Blue Paint Can", player),
set_rule(multiworld.get_location("North Mine Inside Blue Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Paul
set_rule(world.get_location("Museum Paul Mission End", player),
set_rule(multiworld.get_location("Museum Paul Mission End", player),
lambda state: state.has("Remote Explosive x8", player))
# lambda state: state.has("Remote Explosive", player, 8)) # TODO: Add an option to split remote explosives
# West Beach
set_rule(world.get_location("West Beach Chest Scraps 1", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("West Beach Chest Scraps 2", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("West Beach Chest Scraps 3", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("West Beach Chest Scraps 4", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("West Beach Chest Scraps 5", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("West Beach Chest Scraps 6", player),
set_rule(multiworld.get_location("West Beach Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
# Caravan
set_rule(world.get_location("Caravan Chest Scraps 1", player),
set_rule(multiworld.get_location("Caravan Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Caravan Chest Scraps 2", player),
set_rule(multiworld.get_location("Caravan Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Caravan Chest Scraps 3", player),
set_rule(multiworld.get_location("Caravan Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Caravan Chest Scraps 4", player),
set_rule(multiworld.get_location("Caravan Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Caravan Chest Scraps 5", player),
set_rule(multiworld.get_location("Caravan Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Ronny
set_rule(world.get_location("Towers Ronny Mission End", player),
set_rule(multiworld.get_location("Towers Ronny Mission End", player),
lambda state: state.has("Employment Contracts", player))
# North Beach
set_rule(world.get_location("North Beach Chest Scraps 1", player),
set_rule(multiworld.get_location("North Beach Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("North Beach Chest Scraps 2", player),
set_rule(multiworld.get_location("North Beach Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("North Beach Chest Scraps 3", player),
set_rule(multiworld.get_location("North Beach Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("North Beach Chest Scraps 4", player),
set_rule(multiworld.get_location("North Beach Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
# Mine Shaft
set_rule(world.get_location("Mine Shaft Chest Scraps 1", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 2", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 3", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 4", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 5", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 6", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 6", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Mine Shaft Chest Scraps 7", player),
set_rule(multiworld.get_location("Mine Shaft Chest Scraps 7", player),
lambda state: state.has("Lockpicks", player))
# Mob Camp
set_rule(world.get_entrance("Mob Camp Locked Door", player),
set_rule(multiworld.get_entrance("Mob Camp Locked Door", player),
lambda state: state.has("Mob Camp Key", player))
set_rule(world.get_location("Mob Camp Locked Room Stolen Bob", player),
set_rule(multiworld.get_location("Mob Camp Locked Room Stolen Bob", player),
lambda state: state.has("Broken Bob", player))
# Mountain Ruin
set_rule(world.get_entrance("Mountain Ruin Gate", player),
set_rule(multiworld.get_entrance("Mountain Ruin Gate", player),
lambda state: state.has("Mountain Ruin Key", player))
set_rule(world.get_location("Mountain Ruin Inside Red Paint Can", player),
set_rule(multiworld.get_location("Mountain Ruin Inside Red Paint Can", player),
lambda state: state.has("Lockpicks", player))
# Prism Temple
set_rule(world.get_location("Prism Temple Chest Scraps 1", player),
set_rule(multiworld.get_location("Prism Temple Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Prism Temple Chest Scraps 2", player),
set_rule(multiworld.get_location("Prism Temple Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Prism Temple Chest Scraps 3", player),
set_rule(multiworld.get_location("Prism Temple Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
# Pickle Lady
set_rule(world.get_location("Pickle Val Jar of Pickles", player),
set_rule(multiworld.get_location("Pickle Val Jar of Pickles", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Pickle Val Pickle Lady Mission End", player),
set_rule(multiworld.get_location("Pickle Val Pickle Lady Mission End", player),
lambda state: state.has("Jar of Pickles", player))
# Morse Bunker
set_rule(world.get_location("Morse Bunker Chest Scraps 1", player),
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 1", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Morse Bunker Chest Scraps 2", player),
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 2", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Morse Bunker Chest Scraps 3", player),
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 3", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Morse Bunker Chest Scraps 4", player),
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 4", player),
lambda state: state.has("Lockpicks", player))
set_rule(world.get_location("Morse Bunker Chest Scraps 5", player),
set_rule(multiworld.get_location("Morse Bunker Chest Scraps 5", player),
lambda state: state.has("Lockpicks", player))
# Add rules to reach the "Go mode"
set_rule(world.get_location("Final Boss", player),
set_rule(multiworld.get_location("Final Boss", player),
lambda state: state.has("Temple Key", player)
and state.has("Green Egg", player)
and state.has("Blue Egg", player)
and state.has("Red Egg", player))
world.completion_condition[player] = lambda state: state.has("Victory", player)
multiworld.completion_condition[player] = lambda state: state.has("Victory", player)
-47
View File
@@ -1,47 +0,0 @@
# Donkey Kong Country 3 - Changelog
## v1.1
### Features:
- KONGsanity option (Collect all KONG letters in each level for a check)
- Autosave option
- Difficulty option
- MERRY option
- Handle collected/co-op locations
### Bug Fixes:
- Fixed Mekanos softlock
- Prevent Brothers Bear giving extra Banana Birds
- Fixed Banana Bird Mother check sending prematurely
- Fix Logic bug with Krematoa level costs
## v1.0
### Features:
- Goal
- Knautilus
- Scuttle the Knautilus in Krematoa and defeat Baron K. Roolenstein to win
- Banana Bird Hunt
- Find the Banana Birds and rescue their mother to win
- Locations included:
- Level Flags
- Bonuses
- DK Coins
- Banana Bird Caves
- Items included:
- Progressive Boat Upgrade
- Three are placed into the item pool (Patch -> First Ski -> Second Ski)
- Bonus Coins
- DK Coins
- Krematoa Cogs
- Bear Coins
- 1-Up Balloons
- Level Shuffle is supported
- Music Shuffle is supported
- Kong Palette options are supported
- Starting life count can be set
-229
View File
@@ -1,229 +0,0 @@
import logging
from NetUtils import ClientStatus, color
from worlds.AutoSNIClient import SNIClient
snes_logger = logging.getLogger("SNES")
# FXPAK Pro protocol memory mapping used by SNI
ROM_START = 0x000000
WRAM_START = 0xF50000
WRAM_SIZE = 0x20000
SRAM_START = 0xE00000
DKC3_ROMNAME_START = 0x00FFC0
DKC3_ROMHASH_START = 0x7FC0
ROMNAME_SIZE = 0x15
ROMHASH_SIZE = 0x15
DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632
DKC3_FILE_NAME_ADDR = WRAM_START + 0x5D9
DEATH_LINK_ACTIVE_ADDR = DKC3_ROMNAME_START + 0x15 # DKC3_TODO: Find a permanent home for this
class DKC3SNIClient(SNIClient):
game = "Donkey Kong Country 3"
patch_suffix = ".apdkc3"
async def deathlink_kill_player(self, ctx):
pass
# DKC3_TODO: Handle Receiving Deathlink
async def validate_rom(self, ctx):
from SNIClient import snes_read
rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3":
return False
ctx.game = self.game
ctx.items_handling = 0b111 # remote items
ctx.rom = rom_name
#death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1)
## DKC3_TODO: Handle Deathlink
#if death_link:
# ctx.allow_collect = bool(death_link[0] & 0b100)
# await ctx.update_death_link(bool(death_link[0] & 0b1))
return True
async def game_watcher(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
# DKC3_TODO: Handle Deathlink
save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5)
if save_file_name is None or save_file_name[0] == 0x00 or save_file_name == bytes([0x55] * 0x05):
# We haven't loaded a save file
return
new_checks = []
from .Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map
location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81)
for loc_id, loc_data in location_rom_data.items():
if loc_id not in ctx.locations_checked:
data = location_ram_data[loc_data[0] - 0x5FE]
masked_data = data & (1 << loc_data[1])
bit_set = (masked_data != 0)
invert_bit = ((len(loc_data) >= 3) and loc_data[2])
if bit_set != invert_bit:
# DKC3_TODO: Handle non-included checks
new_checks.append(loc_id)
verify_save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5)
if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name == bytes([0x55] * 0x05) or verify_save_file_name != save_file_name:
# We have somehow exited the save file (or worse)
ctx.rom = None
return
rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
if rom != ctx.rom:
ctx.rom = None
# We have somehow loaded a different ROM
return
for new_check_id in new_checks:
ctx.locations_checked.add(new_check_id)
location = ctx.location_names.lookup_in_game(new_check_id)
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])
# DKC3_TODO: Make this actually visually display new things received (ASM Hook required)
recv_count = await snes_read(ctx, DKC3_RECV_PROGRESS_ADDR, 1)
recv_index = recv_count[0]
if recv_index < len(ctx.items_received):
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, DKC3_RECV_PROGRESS_ADDR, bytes([recv_index]))
if item.item in item_rom_data:
item_count = await snes_read(ctx, WRAM_START + item_rom_data[item.item][0], 0x1)
new_item_count = item_count[0] + 1
for address in item_rom_data[item.item]:
snes_buffered_write(ctx, WRAM_START + address, bytes([new_item_count]))
# Handle Coin Displays
current_level = await snes_read(ctx, WRAM_START + 0x5E3, 0x5)
overworld_locked = ((await snes_read(ctx, WRAM_START + 0x5FC, 0x1))[0] == 0x01)
if item.item == 0xDC3002 and not overworld_locked and (current_level[0] == 0x0A and current_level[2] == 0x00 and current_level[4] == 0x03):
# Bazaar and Barter
item_count = await snes_read(ctx, WRAM_START + 0xB02, 0x1)
new_item_count = item_count[0] + 1
snes_buffered_write(ctx, WRAM_START + 0xB02, bytes([new_item_count]))
elif item.item == 0xDC3002 and not overworld_locked and current_level[0] == 0x04:
# Swanky
item_count = await snes_read(ctx, WRAM_START + 0xA26, 0x1)
new_item_count = item_count[0] + 1
snes_buffered_write(ctx, WRAM_START + 0xA26, bytes([new_item_count]))
elif item.item == 0xDC3003 and not overworld_locked and (current_level[0] == 0x0A and current_level[2] == 0x08 and current_level[4] == 0x01):
# Boomer
item_count = await snes_read(ctx, WRAM_START + 0xB02, 0x1)
new_item_count = item_count[0] + 1
snes_buffered_write(ctx, WRAM_START + 0xB02, bytes([new_item_count]))
else:
# Handle Patch and Skis
if item.item == 0xDC3007:
num_upgrades = 1
inventory = await snes_read(ctx, WRAM_START + 0x605, 0xF)
if (inventory[0] & 0x02):
num_upgrades = 3
elif (inventory[13] & 0x08) or (inventory[0] & 0x01):
num_upgrades = 2
if num_upgrades == 1:
snes_buffered_write(ctx, WRAM_START + 0x605, bytes([inventory[0] | 0x01]))
if inventory[4] == 0:
snes_buffered_write(ctx, WRAM_START + 0x609, bytes([0x01]))
elif inventory[6] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60B, bytes([0x01]))
elif inventory[8] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60D, bytes([0x01]))
elif inventory[10] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60F, bytes([0x01]))
cove_mekanos_progress = await snes_read(ctx, WRAM_START + 0x691, 0x2)
snes_buffered_write(ctx, WRAM_START + 0x691, bytes([cove_mekanos_progress[0] | 0x01]))
snes_buffered_write(ctx, WRAM_START + 0x692, bytes([cove_mekanos_progress[1] | 0x01]))
elif num_upgrades == 2:
snes_buffered_write(ctx, WRAM_START + 0x605, bytes([inventory[0] | 0x02]))
if inventory[4] == 0:
snes_buffered_write(ctx, WRAM_START + 0x609, bytes([0x02]))
elif inventory[6] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60B, bytes([0x02]))
elif inventory[8] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60D, bytes([0x02]))
elif inventory[10] == 0:
snes_buffered_write(ctx, WRAM_START + 0x60F, bytes([0x02]))
elif num_upgrades == 3:
snes_buffered_write(ctx, WRAM_START + 0x606, bytes([inventory[1] | 0x20]))
k3_ridge_progress = await snes_read(ctx, WRAM_START + 0x693, 0x2)
snes_buffered_write(ctx, WRAM_START + 0x693, bytes([k3_ridge_progress[0] | 0x01]))
snes_buffered_write(ctx, WRAM_START + 0x694, bytes([k3_ridge_progress[1] | 0x01]))
elif item.item == 0xDC3000:
# Handle Victory
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
else:
print("Item Not Recognized: ", item.item)
pass
await snes_flush_writes(ctx)
# Handle Collected Locations
levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60)
tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60)
for loc_id in ctx.checked_locations:
if loc_id not in ctx.locations_checked and loc_id not in boss_location_ids:
loc_data = location_rom_data[loc_id]
data = await snes_read(ctx, WRAM_START + loc_data[0], 1)
invert_bit = ((len(loc_data) >= 3) and loc_data[2])
if not invert_bit:
masked_data = data[0] | (1 << loc_data[1])
snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
if (loc_data[1] == 1):
# Make the next levels accessible
level_id = loc_data[0] - 0x632
tile_id = levels_to_tiles[level_id] if levels_to_tiles[level_id] != 0xFF else level_id
tile_id = tile_id + 0x632
if tile_id in level_unlock_map:
for next_level_address in level_unlock_map[tile_id]:
next_level_id = next_level_address - 0x632
next_tile_id = tiles_to_levels[next_level_id] if tiles_to_levels[next_level_id] != 0xFF else next_level_id
next_tile_id = next_tile_id + 0x632
next_data = await snes_read(ctx, WRAM_START + next_tile_id, 1)
snes_buffered_write(ctx, WRAM_START + next_tile_id, bytes([next_data[0] | 0x01]))
await snes_flush_writes(ctx)
else:
masked_data = data[0] & ~(1 << loc_data[1])
snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
await snes_flush_writes(ctx)
ctx.locations_checked.add(loc_id)
# Calculate Boomer Cost Text
boomer_cost_text = await snes_read(ctx, WRAM_START + 0xAAFD, 2)
if boomer_cost_text[0] == 0x31 and boomer_cost_text[1] == 0x35:
boomer_cost = await snes_read(ctx, ROM_START + 0x349857, 1)
boomer_cost_tens = int(boomer_cost[0]) // 10
boomer_cost_ones = int(boomer_cost[0]) % 10
snes_buffered_write(ctx, WRAM_START + 0xAAFD, bytes([0x30 + boomer_cost_tens, 0x30 + boomer_cost_ones]))
await snes_flush_writes(ctx)
boomer_final_cost_text = await snes_read(ctx, WRAM_START + 0xAB9B, 2)
if boomer_final_cost_text[0] == 0x32 and boomer_final_cost_text[1] == 0x35:
boomer_cost = await snes_read(ctx, ROM_START + 0x349857, 1)
boomer_cost_tens = boomer_cost[0] // 10
boomer_cost_ones = boomer_cost[0] % 10
snes_buffered_write(ctx, WRAM_START + 0xAB9B, bytes([0x30 + boomer_cost_tens, 0x30 + boomer_cost_ones]))
await snes_flush_writes(ctx)
-52
View File
@@ -1,52 +0,0 @@
import typing
from BaseClasses import Item
from .Names import ItemName
class ItemData(typing.NamedTuple):
code: typing.Optional[int]
progression: bool
quantity: int = 1
event: bool = False
class DKC3Item(Item):
game: str = "Donkey Kong Country 3"
# Separate tables for each type of item.
junk_table = {
ItemName.one_up_balloon: ItemData(0xDC3001, False),
ItemName.bear_coin: ItemData(0xDC3002, False),
}
collectable_table = {
ItemName.bonus_coin: ItemData(0xDC3003, True),
ItemName.dk_coin: ItemData(0xDC3004, True),
ItemName.banana_bird: ItemData(0xDC3005, True),
ItemName.krematoa_cog: ItemData(0xDC3006, True),
ItemName.progressive_boat: ItemData(0xDC3007, True),
}
inventory_table = {
ItemName.present: ItemData(0xDC3008, True),
ItemName.bowling_ball: ItemData(0xDC3009, True),
ItemName.shell: ItemData(0xDC300A, True),
ItemName.mirror: ItemData(0xDC300B, True),
ItemName.flower: ItemData(0xDC300C, True),
ItemName.wrench: ItemData(0xDC300D, True),
}
event_table = {
ItemName.victory: ItemData(0xDC3000, True),
}
# Complete item table.
item_table = {
**junk_table,
**collectable_table,
**event_table,
}
lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code}
-27
View File
@@ -1,27 +0,0 @@
Modified MIT License
Copyright (c) 2025 PoryGone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to
the following conditions:
No copy or substantial portion of the Software shall be sublicensed or relicensed
without the express written permission of the copyright holder(s)
No copy or substantial portion of the Software shall be sold without the express
written permission of the copyright holder(s)
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-115
View File
@@ -1,115 +0,0 @@
from .Names import LocationName
class DKC3Level():
nameIDAddress: int
levelIDAddress: int
nameID: int
levelID: int
def __init__(self, nameIDAddress: int, levelIDAddress: int, nameID: int, levelID: int):
self.nameIDAddress = nameIDAddress
self.levelIDAddress = levelIDAddress
self.nameID = nameID
self.levelID = levelID
level_dict = {
LocationName.lakeside_limbo_region: DKC3Level(0x34D19C, 0x34D19D, 0x01, 0x25),
LocationName.doorstop_dash_region: DKC3Level(0x34D1A7, 0x34D1A8, 0x02, 0x28),
LocationName.tidal_trouble_region: DKC3Level(0x34D1BD, 0x34D1BE, 0x04, 0x27),
LocationName.skiddas_row_region: DKC3Level(0x34D1C8, 0x34D1C9, 0x05, 0x2B),
LocationName.murky_mill_region: DKC3Level(0x34D1D3, 0x34D1D4, 0x0D, 0x2A),
LocationName.barrel_shield_bust_up_region: DKC3Level(0x34D217, 0x34D218, 0x0B, 0x30),
LocationName.riverside_race_region: DKC3Level(0x34D22D, 0x34D22E, 0x0C, 0x32),
LocationName.squeals_on_wheels_region: DKC3Level(0x34D238, 0x34D239, 0x06, 0x29),
LocationName.springin_spiders_region: DKC3Level(0x34D24E, 0x34D24F, 0x0E, 0x2F),
LocationName.bobbing_barrel_brawl_region: DKC3Level(0x34D264, 0x34D265, 0x37, 0x34),
LocationName.bazzas_blockade_region: DKC3Level(0x34D29D, 0x34D29E, 0x14, 0x35),
LocationName.rocket_barrel_ride_region: DKC3Level(0x34D2A8, 0x34D2A9, 0x15, 0x38),
LocationName.kreeping_klasps_region: DKC3Level(0x34D2BE, 0x34D2BF, 0x16, 0x26),
LocationName.tracker_barrel_trek_region: DKC3Level(0x34D2D4, 0x34D2D5, 0x17, 0x39),
LocationName.fish_food_frenzy_region: DKC3Level(0x34D2DF, 0x34D2E0, 0x18, 0x36),
LocationName.fire_ball_frenzy_region: DKC3Level(0x34D30D, 0x34D30E, 0x1B, 0x3B),
LocationName.demolition_drain_pipe_region: DKC3Level(0x34D323, 0x34D324, 0x1D, 0x40),
LocationName.ripsaw_rage_region: DKC3Level(0x34D339, 0x34D33A, 0x1E, 0x2E),
LocationName.blazing_bazookas_region: DKC3Level(0x34D34F, 0x34D350, 0x1F, 0x3C),
LocationName.low_g_labyrinth_region: DKC3Level(0x34D35A, 0x34D35B, 0x20, 0x3E),
LocationName.krevice_kreepers_region: DKC3Level(0x34D388, 0x34D389, 0x23, 0x41),
LocationName.tearaway_toboggan_region: DKC3Level(0x34D393, 0x34D394, 0x24, 0x2D),
LocationName.barrel_drop_bounce_region: DKC3Level(0x34D39E, 0x34D39F, 0x25, 0x3A),
LocationName.krack_shot_kroc_region: DKC3Level(0x34D3A9, 0x34D3AA, 0x26, 0x3D),
LocationName.lemguin_lunge_region: DKC3Level(0x34D3B4, 0x34D3B5, 0x27, 0x2C),
LocationName.buzzer_barrage_region: DKC3Level(0x34D40E, 0x34D40F, 0x2B, 0x44),
LocationName.kong_fused_cliffs_region: DKC3Level(0x34D424, 0x34D425, 0x2D, 0x42),
LocationName.floodlit_fish_region: DKC3Level(0x34D42F, 0x34D430, 0x2E, 0x37),
LocationName.pothole_panic_region: DKC3Level(0x34D43A, 0x34D43B, 0x2F, 0x45),
LocationName.ropey_rumpus_region: DKC3Level(0x34D450, 0x34D451, 0x30, 0x43),
LocationName.konveyor_rope_clash_region: DKC3Level(0x34D489, 0x34D48A, 0x38, 0x48),
LocationName.creepy_caverns_region: DKC3Level(0x34D49F, 0x34D4A0, 0x36, 0x46),
LocationName.lightning_lookout_region: DKC3Level(0x34D4AA, 0x34D4AB, 0x10, 0x33),
LocationName.koindozer_klamber_region: DKC3Level(0x34D4C0, 0x34D4C1, 0x34, 0x47),
LocationName.poisonous_pipeline_region: DKC3Level(0x34D4D6, 0x34D4D7, 0x39, 0x3F),
LocationName.stampede_sprint_region: DKC3Level(0x34D51A, 0x34D51B, 0x3D, 0x49),
LocationName.criss_cross_cliffs_region: DKC3Level(0x34D525, 0x34D526, 0x3E, 0x4A),
LocationName.tyrant_twin_tussle_region: DKC3Level(0x34D530, 0x34D531, 0x3F, 0x4B),
LocationName.swoopy_salvo_region: DKC3Level(0x34D53B, 0x34D53C, 0x40, 0x31),
#LocationName.rocket_rush_region: DKC3Level(0x34D546, 0x34D547, 0x05, 0x4C), # Rocket Rush is not getting shuffled
}
level_list = [
LocationName.lakeside_limbo_region,
LocationName.doorstop_dash_region,
LocationName.tidal_trouble_region,
LocationName.skiddas_row_region,
LocationName.murky_mill_region,
LocationName.barrel_shield_bust_up_region,
LocationName.riverside_race_region,
LocationName.squeals_on_wheels_region,
LocationName.springin_spiders_region,
LocationName.bobbing_barrel_brawl_region,
LocationName.bazzas_blockade_region,
LocationName.rocket_barrel_ride_region,
LocationName.kreeping_klasps_region,
LocationName.tracker_barrel_trek_region,
LocationName.fish_food_frenzy_region,
LocationName.fire_ball_frenzy_region,
LocationName.demolition_drain_pipe_region,
LocationName.ripsaw_rage_region,
LocationName.blazing_bazookas_region,
LocationName.low_g_labyrinth_region,
LocationName.krevice_kreepers_region,
LocationName.tearaway_toboggan_region,
LocationName.barrel_drop_bounce_region,
LocationName.krack_shot_kroc_region,
LocationName.lemguin_lunge_region,
LocationName.buzzer_barrage_region,
LocationName.kong_fused_cliffs_region,
LocationName.floodlit_fish_region,
LocationName.pothole_panic_region,
LocationName.ropey_rumpus_region,
LocationName.konveyor_rope_clash_region,
LocationName.creepy_caverns_region,
LocationName.lightning_lookout_region,
LocationName.koindozer_klamber_region,
LocationName.poisonous_pipeline_region,
LocationName.stampede_sprint_region,
LocationName.criss_cross_cliffs_region,
LocationName.tyrant_twin_tussle_region,
LocationName.swoopy_salvo_region,
#LocationName.rocket_rush_region,
]

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