forked from mirror/Archipelago
Compare commits
204 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30fa0658b0 | |||
| 44a0c44036 | |||
|
|
d83da1b818 | ||
|
|
0de09cd794 | ||
|
|
48c201af19 | ||
|
|
b0300d3063 | ||
|
|
e0e34894a3 | ||
|
|
18e3a8911f | ||
|
|
c505b1c32c | ||
|
|
e22e434258 | ||
|
|
8b91f9ff72 | ||
|
|
fadcfbdfea | ||
|
|
3c4c294f9c | ||
|
|
27a7e538df | ||
|
|
cb0cadcc5f | ||
|
|
2e1035a29f | ||
|
|
21c7f3cd92 | ||
|
|
13b6a5f4b2 | ||
|
|
78e8082a6f | ||
|
|
1de91fab67 | ||
|
|
4ef5436559 | ||
|
|
f2a6a769b0 | ||
|
|
8a767bd2ad | ||
|
|
7df243b860 | ||
|
|
f35d91933b | ||
|
|
286769a0f3 | ||
|
|
1dd91ec85b | ||
|
|
6adeb8b95e | ||
|
|
3e0d42bf9e | ||
|
|
41e22dabda | ||
|
|
39e7ee315e | ||
|
|
3e032e6cd6 | ||
|
|
609f4af600 | ||
|
|
4c27e35445 | ||
|
|
b0c967c039 | ||
|
|
c51da00bfb | ||
|
|
f3389f5d8b | ||
|
|
3b1971be66 | ||
|
|
4cb518930c | ||
|
|
c835bff570 | ||
|
|
6ee02fc62d | ||
|
|
8095f922bc | ||
|
|
77e5f3733e | ||
|
|
c47687dd21 | ||
|
|
8662433142 | ||
|
|
5f073c2a76 | ||
|
|
c5d67dd97a | ||
|
|
9b421450b1 | ||
|
|
a6740e7be3 | ||
|
|
65ef35f1b4 | ||
|
|
520253e762 | ||
|
|
aa3614a32b | ||
|
|
94492c45cb | ||
|
|
8f261bb27c | ||
|
|
ddd08342c8 | ||
|
|
c7db213ee9 | ||
|
|
220248dd3d | ||
|
|
5932160f15 | ||
|
|
76e0619b79 | ||
|
|
646a52a2e7 | ||
|
|
e1322df8b0 | ||
|
|
092a9dc6bd | ||
|
|
9f71fe707f | ||
|
|
b8311a62e7 | ||
|
|
13830ff4cb | ||
|
|
c1b858b2cf | ||
|
|
a035ac579c | ||
|
|
20c10e33c4 | ||
|
|
a4e4ce1c72 | ||
|
|
983936af8c | ||
|
|
62dfeac441 | ||
|
|
b81e1a228a | ||
|
|
5899920e48 | ||
|
|
8dee460397 | ||
|
|
cda54e0bea | ||
|
|
0554bf4e2d | ||
|
|
b92803e77f | ||
|
|
69e83071ff | ||
|
|
875765e6dc | ||
|
|
db56e26df9 | ||
|
|
5a88641228 | ||
|
|
16559e7595 | ||
|
|
d594d5d4a7 | ||
|
|
e950a2fa58 | ||
|
|
1df38cb782 | ||
|
|
c6400b6673 | ||
|
|
dbf2325c01 | ||
|
|
dd5b25399a | ||
|
|
8178ee4e58 | ||
|
|
ad1b41ea81 | ||
|
|
efd8528db0 | ||
|
|
e54a15978f | ||
|
|
d78b9ded2d | ||
|
|
53e8130c9c | ||
|
|
55c70a5ba8 | ||
|
|
ebbdd7bfda | ||
|
|
863f161466 | ||
|
|
9305ecb3bc | ||
|
|
0002bb8e5a | ||
|
|
b42fb77451 | ||
|
|
5a8e166289 | ||
|
|
5fa719143c | ||
|
|
a906f139c3 | ||
|
|
56363ea7e7 | ||
|
|
01e1e1fe11 | ||
|
|
4477dc7a66 | ||
|
|
45994e344e | ||
|
|
51d5e1afae | ||
|
|
577b958c4d | ||
|
|
ce38d8ced6 | ||
|
|
d65fcf286d | ||
|
|
5a6a0b37d6 | ||
|
|
4a0a65d604 | ||
|
|
d25abfc305 | ||
|
|
0905e3ce32 | ||
|
|
ac84b272c5 | ||
|
|
e8a63abfa4 | ||
|
|
3fa2745c37 | ||
|
|
775065715d | ||
|
|
4e608b13ae | ||
|
|
886cc68051 | ||
|
|
146a314d22 | ||
|
|
18cf1bce36 | ||
|
|
f7e3f4e589 | ||
|
|
9f9765b78d | ||
|
|
8ae1a7da32 | ||
|
|
08ea3fe225 | ||
|
|
b81be6b4fc | ||
|
|
f1aca0fc46 | ||
|
|
d88fe99780 | ||
|
|
360a1384f2 | ||
|
|
d089b00ad5 | ||
|
|
c05a2adc38 | ||
|
|
7631242621 | ||
|
|
df48c3e718 | ||
|
|
9a755e64b2 | ||
|
|
34d362a003 | ||
|
|
b75cce5d41 | ||
|
|
a07faca2d9 | ||
|
|
8a1a715dc4 | ||
|
|
60a192b1b6 | ||
|
|
3b721e0365 | ||
|
|
3e16c20fce | ||
|
|
ec2c39e82f | ||
|
|
23d319247f | ||
|
|
c2c488410f | ||
|
|
8ea49e76db | ||
|
|
d834ecec6a | ||
|
|
f3000a89d4 | ||
|
|
aa2774a5d5 | ||
|
|
f9630fa13b | ||
|
|
e0cbf77dae | ||
|
|
447f8fba20 | ||
|
|
e60ea1765c | ||
|
|
2d15c23681 | ||
|
|
c2f76d81ab | ||
|
|
8b737cad21 | ||
|
|
fd968d749e | ||
|
|
32a021096b | ||
|
|
3c819ec781 | ||
|
|
01e64a2b69 | ||
|
|
5e08c8bd98 | ||
|
|
24aa4af7c2 | ||
|
|
b3c323ede3 | ||
|
|
3ec1e9184b | ||
|
|
5055f87034 | ||
|
|
3bb43b266f | ||
|
|
c2094a9fc4 | ||
|
|
b82878130c | ||
|
|
8fbd3569ce | ||
|
|
494381b272 | ||
|
|
7422b10a3d | ||
|
|
e4b5591582 | ||
|
|
557a284afd | ||
|
|
75eb2660ce | ||
|
|
34e13c5e5a | ||
|
|
d098372913 | ||
|
|
7e8746c01b | ||
|
|
93d3d8b084 | ||
|
|
98273ddad9 | ||
|
|
c408c53598 | ||
|
|
cde73c5a2b | ||
|
|
d7eb95a2ee | ||
|
|
a2f8877810 | ||
|
|
5779dda937 | ||
|
|
d597bc40a2 | ||
|
|
4a41550cad | ||
|
|
e4fd06482e | ||
|
|
dba03e3a76 | ||
|
|
4b2298e168 | ||
|
|
283badfc7e | ||
|
|
088f2cc269 | ||
|
|
ea40156194 | ||
|
|
0bf48d7a1b | ||
|
|
14f261b1dd | ||
|
|
bec625621a | ||
|
|
19db58907a | ||
|
|
77808d3ae9 | ||
|
|
b2b0d15add | ||
|
|
ecadb301c0 | ||
|
|
360ad7197b | ||
|
|
96ae2235d1 | ||
|
|
37b87e3fde | ||
|
|
5b6714d2c0 |
4
.github/pyright-config.json
vendored
4
.github/pyright-config.json
vendored
@@ -2,11 +2,15 @@
|
||||
"include": [
|
||||
"../BizHawkClient.py",
|
||||
"../Patch.py",
|
||||
"../rule_builder/cached_world.py",
|
||||
"../rule_builder/options.py",
|
||||
"../rule_builder/rules.py",
|
||||
"../test/param.py",
|
||||
"../test/general/test_groups.py",
|
||||
"../test/general/test_helpers.py",
|
||||
"../test/general/test_memory.py",
|
||||
"../test/general/test_names.py",
|
||||
"../test/general/test_rule_builder.py",
|
||||
"../test/multiworld/__init__.py",
|
||||
"../test/multiworld/test_multiworlds.py",
|
||||
"../test/netutils/__init__.py",
|
||||
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -1,4 +1,5 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
# This workflow will build a release-like distribution when manually dispatched:
|
||||
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
|
||||
|
||||
name: Build
|
||||
|
||||
@@ -24,10 +25,10 @@ env:
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-10-19'
|
||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
id-token: 'write'
|
||||
|
||||
143
.github/workflows/docker.yml
vendored
143
.github/workflows/docker.yml
vendored
@@ -11,144 +11,43 @@ on:
|
||||
- "!.github/workflows/**"
|
||||
- ".github/workflows/docker.yml"
|
||||
branches:
|
||||
- "*"
|
||||
- "dock-dev"
|
||||
tags:
|
||||
- "v?[0-9]+.[0-9]+.[0-9]*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-name: ${{ steps.image.outputs.name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
package-name: ${{ steps.package.outputs.name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set lowercase image name
|
||||
id: image
|
||||
run: |
|
||||
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set package name
|
||||
id: package
|
||||
run: |
|
||||
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=ref,event=branch,enable={{is_not_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=nightly,enable={{is_default_branch}}
|
||||
|
||||
- name: Compute final tags
|
||||
id: final-tags
|
||||
run: |
|
||||
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
|
||||
# Check if latest is already in tags to avoid duplicates
|
||||
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
|
||||
tags+=("$full_latest")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set multiline output
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: amd64
|
||||
runner: ubuntu-latest
|
||||
suffix: amd64
|
||||
cache-scope: amd64
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
suffix: arm64
|
||||
cache-scope: arm64
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Compute suffixed tags
|
||||
id: tags
|
||||
run: |
|
||||
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
|
||||
suffixed=()
|
||||
for t in "${tags[@]}"; do
|
||||
suffixed+=("$t-${{ matrix.suffix }}")
|
||||
done
|
||||
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ubufugu/dockipelago
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
labels: ${{ needs.prepare.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||
provenance: false
|
||||
|
||||
manifest:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
|
||||
|
||||
for tag in "${tag_array[@]}"; do
|
||||
docker manifest create "$tag" \
|
||||
"$tag-amd64" \
|
||||
"$tag-arm64"
|
||||
|
||||
docker manifest push "$tag"
|
||||
done
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -12,10 +12,10 @@ env:
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-10-19'
|
||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
id-token: 'write'
|
||||
|
||||
2
.github/workflows/unittests.yml
vendored
2
.github/workflows/unittests.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-subtests pytest-xdist
|
||||
pip install -r ci-requirements.txt
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,7 +63,10 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/datapackage_export.json
|
||||
/custom_worlds
|
||||
# stubgen output
|
||||
/out/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
|
||||
<configuration default="false" name="Build APWorlds" type="PythonConfigurationType" factoryName="Python">
|
||||
<module name="Archipelago" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
@@ -12,8 +12,8 @@
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
|
||||
<option name="PARAMETERS" value="\"Build APWorlds\"" />
|
||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
|
||||
<option name="PARAMETERS" value=""Build APWorlds"" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
<option name="EMULATE_TERMINAL" value="false" />
|
||||
<option name="MODULE_MODE" value="false" />
|
||||
@@ -8,10 +8,10 @@ import secrets
|
||||
import warnings
|
||||
from argparse import Namespace
|
||||
from collections import Counter, deque, defaultdict
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
|
||||
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
|
||||
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
|
||||
import dataclasses
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
@@ -22,6 +22,7 @@ import Utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from entrance_rando import ERPlacementState
|
||||
from rule_builder.rules import Rule
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -85,7 +86,7 @@ class MultiWorld():
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
completion_condition: Dict[int, CollectionRule]
|
||||
indirect_connections: Dict[Region, Set[Entrance]]
|
||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||
priority_locations: Dict[int, Options.PriorityLocations]
|
||||
@@ -766,7 +767,7 @@ class CollectionState():
|
||||
else:
|
||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
@@ -784,13 +785,16 @@ class CollectionState():
|
||||
blocked_connections.update(new_region.exits)
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
||||
|
||||
# Retry connections if the new region can unblock them
|
||||
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
entrances = self.multiworld.indirect_connections.get(new_region)
|
||||
if entrances is not None:
|
||||
relevant_entrances = entrances.intersection(blocked_connections)
|
||||
relevant_entrances.difference_update(queue)
|
||||
queue.extend(relevant_entrances)
|
||||
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
new_connection: bool = True
|
||||
@@ -812,6 +816,7 @@ class CollectionState():
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
new_connection = True
|
||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||
queue.extend(blocked_connections)
|
||||
|
||||
@@ -1169,13 +1174,17 @@ class CollectionState():
|
||||
self.prog_items[player][item] = count
|
||||
|
||||
|
||||
CollectionRule = Callable[[CollectionState], bool]
|
||||
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
|
||||
|
||||
|
||||
class EntranceType(IntEnum):
|
||||
ONE_WAY = 1
|
||||
TWO_WAY = 2
|
||||
|
||||
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
||||
hide_path: bool = False
|
||||
player: int
|
||||
name: str
|
||||
@@ -1362,7 +1371,7 @@ class Region:
|
||||
self,
|
||||
location_name: str,
|
||||
item_name: str | None = None,
|
||||
rule: Callable[[CollectionState], bool] | None = None,
|
||||
rule: CollectionRule | Rule[Any] | None = None,
|
||||
location_type: type[Location] | None = None,
|
||||
item_type: type[Item] | None = None,
|
||||
show_in_spoiler: bool = True,
|
||||
@@ -1390,7 +1399,7 @@ class Region:
|
||||
event_location = location_type(self.player, location_name, None, self)
|
||||
event_location.show_in_spoiler = show_in_spoiler
|
||||
if rule is not None:
|
||||
event_location.access_rule = rule
|
||||
self.multiworld.worlds[self.player].set_rule(event_location, rule)
|
||||
|
||||
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||
|
||||
@@ -1401,7 +1410,7 @@ class Region:
|
||||
return event_item
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1409,8 +1418,8 @@ class Region:
|
||||
:param name: name of the connection being created
|
||||
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
|
||||
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
|
||||
if rule:
|
||||
exit_.access_rule = rule
|
||||
if rule is not None:
|
||||
self.multiworld.worlds[self.player].set_rule(exit_, rule)
|
||||
exit_.connect(connecting_region)
|
||||
return exit_
|
||||
|
||||
@@ -1435,7 +1444,7 @@ class Region:
|
||||
return entrance
|
||||
|
||||
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
||||
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
|
||||
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
@@ -1474,7 +1483,7 @@ class Location:
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
@@ -1551,7 +1560,7 @@ class ItemClassification(IntFlag):
|
||||
skip_balancing = 0b01000
|
||||
""" should technically never occur on its own
|
||||
Item that is logically relevant, but progression balancing should not touch.
|
||||
|
||||
|
||||
Possible reasons for why an item should not be pulled ahead by progression balancing:
|
||||
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
|
||||
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
|
||||
@@ -1559,13 +1568,13 @@ class ItemClassification(IntFlag):
|
||||
deprioritized = 0b10000
|
||||
""" Should technically never occur on its own.
|
||||
Will not be considered for priority locations,
|
||||
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
||||
|
||||
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
||||
|
||||
Should be used for items that would feel bad for the player to find on a priority location.
|
||||
Usually, these are items that are plentiful or insignificant. """
|
||||
|
||||
progression_deprioritized_skip_balancing = 0b11001
|
||||
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
||||
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
||||
these items often want both flags. """
|
||||
|
||||
progression_skip_balancing = 0b01001 # only progression gets balanced
|
||||
@@ -1721,9 +1730,10 @@ class Spoiler:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
if not multiworld.has_beaten_game(state):
|
||||
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
|
||||
"Something went terribly wrong here. "
|
||||
f"Unreachable progression items: {sphere_candidates}")
|
||||
else:
|
||||
self.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
13
CommonClient.py
Normal file → Executable file
13
CommonClient.py
Normal file → Executable file
@@ -24,7 +24,7 @@ if __name__ == "__main__":
|
||||
from MultiServer import CommandProcessor, mark_raw
|
||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from Utils import gui_enabled, Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
import ssl
|
||||
@@ -35,9 +35,6 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@Utils.cache_argsless
|
||||
def get_ssl_context():
|
||||
@@ -65,6 +62,8 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
if self.ctx.ui:
|
||||
self.ctx.ui.stop()
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@@ -323,7 +322,7 @@ class CommonContext:
|
||||
hint_cost: int | None
|
||||
"""Current Hint Cost per Hint from the server"""
|
||||
hint_points: int | None
|
||||
"""Current avaliable Hint Points from the server"""
|
||||
"""Current available Hint Points from the server"""
|
||||
player_names: dict[int, str]
|
||||
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
||||
|
||||
@@ -572,6 +571,10 @@ class CommonContext:
|
||||
return print_json_packet.get("type", "") == "ItemSend" \
|
||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||
|
||||
def is_connection_change(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out connection changes."""
|
||||
return print_json_packet.get("type", "") in ["Join","Part"]
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
148
Generate.py
148
Generate.py
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
|
||||
|
||||
def mystery_argparse(argv: list[str] | None = None):
|
||||
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
@@ -68,7 +68,7 @@ def mystery_argparse(argv: list[str] | None = None):
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
args.plando = PlandoOptions.from_option_string(args.plando)
|
||||
|
||||
return args
|
||||
|
||||
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
else:
|
||||
meta_weights = None
|
||||
|
||||
|
||||
player_id = 1
|
||||
player_files = {}
|
||||
player_id: int = 1
|
||||
player_files: dict[int, str] = {}
|
||||
player_errors: list[str] = []
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||
@@ -135,9 +135,13 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
else:
|
||||
weights_for_file.append(yaml)
|
||||
weights_cache[fname] = tuple(weights_for_file)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
logging.exception(f"Exception reading weights in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
||||
@@ -152,6 +156,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
|
||||
if args.multi == 0:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
raise ValueError(
|
||||
"No individual player files found and number of players is 0. "
|
||||
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
||||
@@ -161,6 +169,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
@@ -171,10 +183,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.name = {}
|
||||
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
for key in category_dict:
|
||||
@@ -189,50 +197,93 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
elif key == "triggers":
|
||||
if "triggers" not in yaml[category_name]:
|
||||
yaml[category_name][key] = []
|
||||
for trigger in option:
|
||||
yaml[category_name][key].append(trigger)
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
player_path_cache = {}
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
|
||||
if args.sameoptions:
|
||||
for fname, yamls in weights_cache.items():
|
||||
try:
|
||||
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
|
||||
except Exception as e:
|
||||
logging.exception(f"Exception reading settings in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
# Exit early here to avoid throwing the same errors again later
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
|
||||
player_path_cache: dict[int, str] = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter = Counter()
|
||||
name_counter: Counter[str] = Counter()
|
||||
args.player_options = {}
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
if not path:
|
||||
player_errors.append(f'No weights specified for player {player}')
|
||||
player += 1
|
||||
continue
|
||||
|
||||
for doc_index, yaml in enumerate(weights_cache[path]):
|
||||
name = yaml.get("name")
|
||||
try:
|
||||
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(args, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(args, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
|
||||
# Invariant: settings_cache[path] and weights_cache[path] have the same length
|
||||
cached = settings_cache[path]
|
||||
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
|
||||
|
||||
# name was not specified
|
||||
if player not in args.name:
|
||||
if path == args.weights_file_path:
|
||||
# weights file, so we need to make the name unique
|
||||
args.name[player] = f"Player{player}"
|
||||
else:
|
||||
# use the filename
|
||||
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||
for k, v in vars(settings_object).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(args, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(args, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
# name was not specified
|
||||
if player not in args.name:
|
||||
if path == args.weights_file_path:
|
||||
# weights file, so we need to make the name unique
|
||||
args.name[player] = f"Player{player}"
|
||||
else:
|
||||
# use the filename
|
||||
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
|
||||
f"(name: {args.name.get(player, name)})")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
|
||||
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
|
||||
|
||||
# increment for each yaml document in the file
|
||||
player += 1
|
||||
|
||||
if len(set(name.lower() for name in args.name.values())) != len(args.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
|
||||
)
|
||||
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
|
||||
return args, seed
|
||||
|
||||
@@ -311,7 +362,7 @@ class SafeFormatter(string.Formatter):
|
||||
return kwargs.get(key, "{" + key + "}")
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
def handle_name(name: str, player: int, name_counter: Counter[str]):
|
||||
name_counter[name.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
@@ -342,7 +393,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
elif isinstance(new_value, list):
|
||||
cleaned_value.extend(new_value)
|
||||
elif isinstance(new_value, dict):
|
||||
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
||||
counter_value = Counter(cleaned_value)
|
||||
counter_value.update(new_value)
|
||||
cleaned_value = dict(counter_value)
|
||||
else:
|
||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
@@ -356,13 +409,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
for element in new_value:
|
||||
cleaned_value.remove(element)
|
||||
elif isinstance(new_value, dict):
|
||||
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
|
||||
counter_value = Counter(cleaned_value)
|
||||
counter_value.subtract(new_value)
|
||||
cleaned_value = dict(counter_value)
|
||||
else:
|
||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
else:
|
||||
cleaned_weights[option_name] = new_weights[option]
|
||||
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
|
||||
# using the same .yaml, so ensure that the new value is a copy.
|
||||
cleaned_value = copy.deepcopy(new_weights[option])
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
new_options = set(cleaned_weights) - set(weights)
|
||||
weights.update(cleaned_weights)
|
||||
if new_options:
|
||||
@@ -385,6 +443,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
if option_key == "triggers":
|
||||
return category_dict[option_key]
|
||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
@@ -440,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
|
||||
try:
|
||||
if option_key in game_weights:
|
||||
if not option.supports_weighting:
|
||||
|
||||
25
Launcher.py
25
Launcher.py
@@ -31,6 +31,10 @@ import settings
|
||||
import Utils
|
||||
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
|
||||
user_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging('Launcher')
|
||||
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
||||
|
||||
|
||||
@@ -75,12 +79,17 @@ def open_patch():
|
||||
launch([*exe, file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls():
|
||||
def generate_yamls(*args):
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
|
||||
parser.add_argument("--skip_open_folder", action="store_true")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
open_folder(target)
|
||||
if not args.skip_open_folder:
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
@@ -213,12 +222,17 @@ def launch(exe, in_terminal=False):
|
||||
|
||||
def create_shortcut(button: Any, component: Component) -> None:
|
||||
from pyshortcuts import make_shortcut
|
||||
script = sys.argv[0]
|
||||
wkdir = Utils.local_path()
|
||||
env = os.environ
|
||||
if "APPIMAGE" in env:
|
||||
script = env["ARGV0"]
|
||||
wkdir = None # defaults to ~ on Linux
|
||||
else:
|
||||
script = sys.argv[0]
|
||||
wkdir = Utils.local_path()
|
||||
|
||||
script = f"{script} \"{component.display_name}\""
|
||||
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
|
||||
startmenu=False, terminal=False, working_dir=wkdir)
|
||||
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
|
||||
button.menu.dismiss()
|
||||
|
||||
|
||||
@@ -483,7 +497,6 @@ def main(args: argparse.Namespace | dict | None = None):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
6
Main.py
6
Main.py
@@ -326,7 +326,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
if current_sphere:
|
||||
spheres.append(dict(current_sphere))
|
||||
|
||||
multidata: NetUtils.MultiData | bytes = {
|
||||
multidata: NetUtils.MultiData = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||
@@ -350,11 +350,11 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
for key in ("slot_data", "er_hint_data"):
|
||||
multidata[key] = convert_to_base_types(multidata[key])
|
||||
|
||||
multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
f.write(serialized_multidata)
|
||||
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
|
||||
@@ -5,15 +5,16 @@ import multiprocessing
|
||||
import warnings
|
||||
|
||||
|
||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
|
||||
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
|
||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
|
||||
"Official 3.11.9 through 3.13.x is supported.")
|
||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
|
||||
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
|
||||
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||
elif sys.version_info < (3, 11, 0):
|
||||
elif not (3, 11, 0) <= sys.version_info < (3, 14, 0):
|
||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
|
||||
|
||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||
_skip_update = bool(
|
||||
|
||||
@@ -69,6 +69,12 @@ def remove_from_list(container, value):
|
||||
|
||||
|
||||
def pop_from_container(container, value):
|
||||
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
|
||||
return container
|
||||
|
||||
if isinstance(container, dict) and value not in container:
|
||||
return container
|
||||
|
||||
try:
|
||||
container.pop(value)
|
||||
except ValueError:
|
||||
@@ -493,7 +499,7 @@ class Context:
|
||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
|
||||
f"however this server is of version {version_tuple}")
|
||||
self.generator_version = Version(*decoded_obj["version"])
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
@@ -911,12 +917,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
|
||||
|
||||
|
||||
async def on_client_connected(ctx: Context, client: Client):
|
||||
players = []
|
||||
for team, clients in ctx.clients.items():
|
||||
for slot, connected_clients in clients.items():
|
||||
if connected_clients:
|
||||
name = ctx.player_names[team, slot]
|
||||
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||
games.add("Archipelago")
|
||||
await ctx.send_msgs(client, [{
|
||||
@@ -1364,7 +1364,10 @@ class CommandProcessor(metaclass=CommandMeta):
|
||||
argname += "=" + parameter.default
|
||||
argtext += argname
|
||||
argtext += " "
|
||||
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
|
||||
method_doc = inspect.getdoc(method)
|
||||
if method_doc is None:
|
||||
method_doc = "(missing help text)"
|
||||
doctext = "\n ".join(method_doc.split("\n"))
|
||||
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
||||
return s
|
||||
|
||||
|
||||
223
Options.py
223
Options.py
@@ -24,6 +24,39 @@ if typing.TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
|
||||
_RANDOM_OPTS = [
|
||||
"random", "random-low", "random-middle", "random-high",
|
||||
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
|
||||
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
|
||||
]
|
||||
|
||||
|
||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||
|
||||
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||
"""
|
||||
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||
# when a != b, so ensure the result is never more than `end`.
|
||||
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||
|
||||
|
||||
def random_weighted_range(text: str, range_start: int, range_end: int):
|
||||
if text == "random-low":
|
||||
return triangular(range_start, range_end, 0.0)
|
||||
elif text == "random-high":
|
||||
return triangular(range_start, range_end, 1.0)
|
||||
elif text == "random-middle":
|
||||
return triangular(range_start, range_end)
|
||||
elif text == "random":
|
||||
return random.randint(range_start, range_end)
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
|
||||
|
||||
|
||||
def roll_percentage(percentage: int | float) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
@@ -417,10 +450,12 @@ class Toggle(NumericOption):
|
||||
def from_text(cls, text: str) -> Toggle:
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
|
||||
return cls(0)
|
||||
else:
|
||||
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
|
||||
return cls(1)
|
||||
else:
|
||||
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
@@ -523,9 +558,9 @@ class Choice(NumericOption):
|
||||
|
||||
class TextChoice(Choice):
|
||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||
value: typing.Union[str, int]
|
||||
value: str | int
|
||||
|
||||
def __init__(self, value: typing.Union[str, int]):
|
||||
def __init__(self, value: str | int):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||
self.value = value
|
||||
@@ -546,7 +581,7 @@ class TextChoice(Choice):
|
||||
return cls(text)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
def get_option_name(cls, value: str | int) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return super().get_option_name(value)
|
||||
@@ -713,33 +748,39 @@ class Range(NumericOption):
|
||||
# these are the conditions where "true" and "false" make sense
|
||||
if text == "true":
|
||||
return cls.from_any(cls.default)
|
||||
else: # "false"
|
||||
return cls(0)
|
||||
return cls(int(text))
|
||||
# "false"
|
||||
return cls(0)
|
||||
|
||||
try:
|
||||
num = int(text)
|
||||
except ValueError:
|
||||
# text is not a number
|
||||
# Handle conditionally acceptable values here rather than in the f-string
|
||||
default = ""
|
||||
truefalse = ""
|
||||
if hasattr(cls, "default"):
|
||||
default = ", default"
|
||||
if cls.range_start == 0 and cls.default != 0:
|
||||
truefalse = ", \"true\", \"false\""
|
||||
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
|
||||
f"<int>{default}, high, low{truefalse}, "
|
||||
f"{', '.join(cls._RANDOM_OPTS)}.")
|
||||
|
||||
return cls(num)
|
||||
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
if text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
@@ -747,14 +788,9 @@ class Range(NumericOption):
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
if textsplit[2] in ("low", "middle", "high"):
|
||||
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
|
||||
return cls(random_weighted_range("random", *random_range))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
@@ -769,18 +805,6 @@ class Range(NumericOption):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||
|
||||
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||
"""
|
||||
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||
# when a != b, so ensure the result is never more than `end`.
|
||||
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||
|
||||
|
||||
class NamedRange(Range):
|
||||
special_range_names: typing.Dict[str, int] = {}
|
||||
@@ -870,7 +894,7 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||
return self.value.__iter__()
|
||||
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
@@ -885,7 +909,8 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||
|
||||
def __getitem__(self, item: str) -> typing.Any:
|
||||
@@ -965,7 +990,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(map(str, value))
|
||||
|
||||
def __contains__(self, item):
|
||||
@@ -975,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
random_str: str | None
|
||||
|
||||
def __init__(self, value: typing.Iterable[str]):
|
||||
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
|
||||
self.value = set(deepcopy(value))
|
||||
self.random_str = random_str
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
check_text = text.lower().split(",")
|
||||
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
|
||||
and len(check_text) == 1 and check_text[0].startswith("random")):
|
||||
return cls((), check_text[0])
|
||||
return cls([option.strip() for option in text.split(",")])
|
||||
|
||||
@classmethod
|
||||
@@ -990,7 +1022,37 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||
if self.random_str and not self.value:
|
||||
choice_list = sorted(self.valid_keys)
|
||||
if self.verify_item_name:
|
||||
choice_list.extend(sorted(world.item_names))
|
||||
if self.verify_location_name:
|
||||
choice_list.extend(sorted(world.location_names))
|
||||
if self.random_str.startswith("random-range-"):
|
||||
textsplit = self.random_str.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
|
||||
f"for player {player_name}")
|
||||
random_range.sort()
|
||||
if random_range[0] < 0 or random_range[1] > len(choice_list):
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
|
||||
if textsplit[2] in ("low", "middle", "high"):
|
||||
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
|
||||
random_range[0], random_range[1])
|
||||
else:
|
||||
choice_count = random_weighted_range("random", random_range[0], random_range[1])
|
||||
else:
|
||||
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
|
||||
self.value = set(random.sample(choice_list, k=choice_count))
|
||||
super(Option, self).verify(world, player_name, plando_options)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
@@ -1018,6 +1080,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
supports_weighting = False
|
||||
display_name = "Plando Texts"
|
||||
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
||||
self.value = list(deepcopy(value))
|
||||
super().__init__()
|
||||
@@ -1144,6 +1208,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
||||
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
|
||||
duplicate_exits: bool = False
|
||||
"""Whether or not exits should be allowed to be duplicate."""
|
||||
|
||||
@@ -1435,6 +1501,7 @@ class DeathLink(Toggle):
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
display_name = "Item Links"
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
rich_text_doc = True
|
||||
default = []
|
||||
schema = Schema([
|
||||
@@ -1519,6 +1586,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||
default = ()
|
||||
supports_weighting = False
|
||||
display_name = "Plando Items"
|
||||
visibility = Visibility.template | Visibility.spoiler
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
||||
self.value = list(deepcopy(value))
|
||||
@@ -1629,7 +1697,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||
def __len__(self) -> int:
|
||||
return len(self.value)
|
||||
|
||||
|
||||
|
||||
class Removed(FreeText):
|
||||
"""This Option has been Removed."""
|
||||
rich_text_doc = True
|
||||
@@ -1715,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
from Utils import local_path, __version__
|
||||
|
||||
full_path: str
|
||||
preset_folder = os.path.join(target_folder, "Presets")
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
os.makedirs(preset_folder, exist_ok=True)
|
||||
|
||||
# clean out old
|
||||
for file in os.listdir(target_folder):
|
||||
@@ -1724,18 +1794,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: Range):
|
||||
data = {option.default: 50}
|
||||
for sub_option in ["random", "random-low", "random-high"]:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
for file in os.listdir(preset_folder):
|
||||
full_path = os.path.join(preset_folder, file)
|
||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||
os.unlink(full_path)
|
||||
|
||||
notes = {}
|
||||
def dictify_range(option: Range, option_val: int | str):
|
||||
data = {option_val: 50}
|
||||
for sub_option in ["random", "random-low", "random-high",
|
||||
f"random-range-{option.range_start}-{option.range_end}"]:
|
||||
if sub_option != option_val:
|
||||
data[sub_option] = 0
|
||||
notes = {
|
||||
"random-low": "random value weighted towards lower values",
|
||||
"random-high": "random value weighted towards higher values",
|
||||
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
|
||||
f"{option.range_start} and {option.range_end}"
|
||||
}
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
notes[name] = f"equivalent to {number}"
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
elif name in data:
|
||||
pass
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
@@ -1751,20 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
presets = world.web.options_presets.copy()
|
||||
presets.update({"": {}})
|
||||
|
||||
option_groups = get_option_groups(world)
|
||||
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
for name, preset in presets.items():
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
preset_name=name,
|
||||
preset=preset,
|
||||
)
|
||||
preset_name = f" - {name}" if name else ""
|
||||
with open(os.path.join(preset_folder if name else target_folder,
|
||||
get_file_safe_name(game_name + preset_name) + ".yaml"),
|
||||
"w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
|
||||
696
OptionsCreator.py
Normal file
696
OptionsCreator.py
Normal file
@@ -0,0 +1,696 @@
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
|
||||
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
||||
ToggleButton, MarkupDropdown, ResizableTextField)
|
||||
from kivy.clock import Clock
|
||||
from kivy.uix.behaviors.button import ButtonBehavior
|
||||
from kivymd.uix.behaviors import RotateBehavior
|
||||
from kivymd.uix.anchorlayout import MDAnchorLayout
|
||||
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
|
||||
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
|
||||
from kivymd.uix.slider import MDSlider
|
||||
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||
from kivymd.uix.menu import MDDropdownMenu
|
||||
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
|
||||
from kivymd.uix.dialog import MDDialog
|
||||
from kivy.core.text.markup import MarkupLabel
|
||||
from kivy.utils import escape_markup
|
||||
from kivy.lang.builder import Builder
|
||||
from kivy.properties import ObjectProperty
|
||||
from textwrap import dedent
|
||||
from copy import deepcopy
|
||||
import Utils
|
||||
import typing
|
||||
import webbrowser
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed,
|
||||
OptionCounter, Visibility)
|
||||
|
||||
|
||||
def validate_url(x):
|
||||
try:
|
||||
result = urlparse(x)
|
||||
return all([result.scheme, result.netloc])
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def filter_tooltip(tooltip):
|
||||
if tooltip is None:
|
||||
tooltip = "No tooltip available."
|
||||
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&") \
|
||||
.replace("[", "&bl;").replace("]", "&br;")
|
||||
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
|
||||
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
|
||||
return escape_markup(tooltip)
|
||||
|
||||
|
||||
def option_can_be_randomized(option: typing.Type[Option]):
|
||||
# most options can be randomized, so we should just check for those that cannot
|
||||
if not option.supports_weighting:
|
||||
return False
|
||||
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_random(value: typing.Any):
|
||||
if not isinstance(value, str):
|
||||
return value # cannot be random if evaluated
|
||||
if value.startswith("random-"):
|
||||
return "random"
|
||||
return value
|
||||
|
||||
|
||||
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
|
||||
pass
|
||||
|
||||
|
||||
class WorldButton(ToggleButton):
|
||||
world_cls: typing.Type[World]
|
||||
|
||||
|
||||
class VisualRange(MDBoxLayout):
|
||||
option: typing.Type[Range]
|
||||
name: str
|
||||
tag: MDLabel = ObjectProperty(None)
|
||||
slider: MDSlider = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_points(*update_args):
|
||||
pass
|
||||
|
||||
self.slider._update_points = update_points
|
||||
|
||||
|
||||
class VisualChoice(MDButton):
|
||||
option: typing.Type[Choice]
|
||||
name: str
|
||||
text: MDButtonText = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class VisualNamedRange(MDBoxLayout):
|
||||
option: typing.Type[NamedRange]
|
||||
name: str
|
||||
range: VisualRange = ObjectProperty(None)
|
||||
choice: MDButton = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
self.range = range_widget
|
||||
self.add_widget(self.range)
|
||||
|
||||
|
||||
class VisualFreeText(ResizableTextField):
|
||||
option: typing.Type[FreeText] | typing.Type[TextChoice]
|
||||
name: str
|
||||
|
||||
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class VisualTextChoice(MDBoxLayout):
|
||||
option: typing.Type[TextChoice]
|
||||
name: str
|
||||
choice: VisualChoice = ObjectProperty(None)
|
||||
text: VisualFreeText = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
|
||||
text: VisualFreeText, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super(MDBoxLayout, self).__init__(*args, **kwargs)
|
||||
self.choice = choice
|
||||
self.text = text
|
||||
self.add_widget(self.choice)
|
||||
self.add_widget(self.text)
|
||||
|
||||
|
||||
class VisualToggle(MDBoxLayout):
|
||||
button: MDIconButton = ObjectProperty(None)
|
||||
option: typing.Type[Toggle]
|
||||
name: str
|
||||
|
||||
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class CounterItemValue(ResizableTextField):
|
||||
pat = re.compile('[^0-9]')
|
||||
|
||||
def insert_text(self, substring, from_undo=False):
|
||||
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
|
||||
|
||||
|
||||
class VisualListSetCounter(MDDialog):
|
||||
button: MDIconButton = ObjectProperty(None)
|
||||
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
|
||||
scrollbox: ScrollBox = ObjectProperty(None)
|
||||
add: MDIconButton = ObjectProperty(None)
|
||||
save: MDButton = ObjectProperty(None)
|
||||
input: ResizableTextField = ObjectProperty(None)
|
||||
dropdown: MDDropdownMenu
|
||||
valid_keys: typing.Iterable[str]
|
||||
|
||||
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
|
||||
name: str, valid_keys: typing.Iterable[str], **kwargs):
|
||||
self.option = option
|
||||
self.name = name
|
||||
self.valid_keys = valid_keys
|
||||
super().__init__(*args, **kwargs)
|
||||
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
|
||||
width=self.input.width, position="bottom")
|
||||
self.input.bind(text=self.on_text)
|
||||
self.input.bind(on_text_validate=self.validate_add)
|
||||
|
||||
def validate_add(self, instance):
|
||||
if self.valid_keys:
|
||||
if self.input.text not in self.valid_keys:
|
||||
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
return
|
||||
|
||||
if not issubclass(self.option, OptionList):
|
||||
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
|
||||
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
return
|
||||
|
||||
self.add_set_item(self.input.text)
|
||||
self.input.set_text(self.input, "")
|
||||
|
||||
def remove_item(self, button: MDIconButton):
|
||||
list_item = button.parent
|
||||
self.scrollbox.layout.remove_widget(list_item)
|
||||
|
||||
def add_set_item(self, key: str, value: int | None = None):
|
||||
text = MDListItemSupportingText(text=key, id="value")
|
||||
if issubclass(self.option, OptionCounter):
|
||||
value_txt = CounterItemValue(text=str(value) if value else "1")
|
||||
item = MDListItem(text,
|
||||
value_txt,
|
||||
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||
item.value = value_txt
|
||||
else:
|
||||
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||
item.text = text
|
||||
self.scrollbox.layout.add_widget(item)
|
||||
|
||||
def on_text(self, instance, value):
|
||||
if not self.valid_keys:
|
||||
return
|
||||
if len(value) >= 3:
|
||||
self.dropdown.items.clear()
|
||||
|
||||
def on_press(txt):
|
||||
split_text = MarkupLabel(text=txt, markup=True).markup
|
||||
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
|
||||
if not text_frag.startswith("[")))
|
||||
self.input.focus = True
|
||||
self.dropdown.dismiss()
|
||||
|
||||
lowered = value.lower()
|
||||
for item_name in self.valid_keys:
|
||||
try:
|
||||
index = item_name.lower().index(lowered)
|
||||
except ValueError:
|
||||
pass # substring not found
|
||||
else:
|
||||
text = escape_markup(item_name)
|
||||
text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
|
||||
self.dropdown.items.append({
|
||||
"text": text,
|
||||
"on_release": lambda txt=text: on_press(txt),
|
||||
"markup": True
|
||||
})
|
||||
if not self.dropdown.parent:
|
||||
self.dropdown.open()
|
||||
else:
|
||||
self.dropdown.dismiss()
|
||||
|
||||
|
||||
class OptionsCreator(ThemedApp):
|
||||
base_title: str = "Archipelago Options Creator"
|
||||
container: ContainerLayout
|
||||
main_layout: MainLayout
|
||||
scrollbox: ScrollBox
|
||||
main_panel: MainLayout
|
||||
player_options: MainLayout
|
||||
option_layout: MainLayout
|
||||
name_input: ResizableTextField
|
||||
game_label: MDLabel
|
||||
current_game: str
|
||||
options: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self):
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
self.icon = r"data/icon.png"
|
||||
self.current_game = ""
|
||||
self.options = {}
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def show_result_snack(text: str) -> None:
|
||||
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
|
||||
def on_export_result(self, text: str | None) -> None:
|
||||
self.container.disabled = False
|
||||
if text is not None:
|
||||
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
|
||||
|
||||
def export_options_background(self, options: dict[str, typing.Any]) -> None:
|
||||
try:
|
||||
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
|
||||
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
|
||||
except Exception:
|
||||
self.on_export_result("Could not open dialog. Already open?")
|
||||
raise
|
||||
|
||||
if not file_name:
|
||||
self.on_export_result(None) # No file selected. No need to show a message for this.
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_name, 'w') as f:
|
||||
f.write(Utils.dump(options, sort_keys=False))
|
||||
f.close()
|
||||
self.on_export_result("File saved successfully.")
|
||||
except Exception:
|
||||
self.on_export_result("Could not save file.")
|
||||
raise
|
||||
|
||||
def export_options(self, button: Widget) -> None:
|
||||
if 0 < len(self.name_input.text) < 17 and self.current_game:
|
||||
import threading
|
||||
options = {
|
||||
"name": self.name_input.text,
|
||||
"description": f"YAML generated by Archipelago {Utils.__version__}.",
|
||||
"game": self.current_game,
|
||||
self.current_game: {k: check_random(v) for k, v in self.options.items()}
|
||||
}
|
||||
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
|
||||
self.container.disabled = True
|
||||
elif not self.name_input.text:
|
||||
self.show_result_snack("Name must not be empty.")
|
||||
elif not self.current_game:
|
||||
self.show_result_snack("You must select a game to play.")
|
||||
else:
|
||||
self.show_result_snack("Name cannot be longer than 16 characters.")
|
||||
|
||||
def create_range(self, option: typing.Type[Range], name: str):
|
||||
def update_text(range_box: VisualRange):
|
||||
self.options[name] = int(range_box.slider.value)
|
||||
range_box.tag.text = str(int(range_box.slider.value))
|
||||
return
|
||||
|
||||
box = VisualRange(option=option, name=name)
|
||||
box.slider.bind(on_touch_move=lambda _, _1: update_text(box))
|
||||
self.options[name] = option.default
|
||||
return box
|
||||
|
||||
def create_named_range(self, option: typing.Type[NamedRange], name: str):
|
||||
def set_to_custom(range_box: VisualNamedRange):
|
||||
if (not self.options[name] == range_box.range.slider.value) \
|
||||
and (not self.options[name] in option.special_range_names or
|
||||
range_box.range.slider.value != option.special_range_names[self.options[name]]):
|
||||
# we should validate the touch here,
|
||||
# but this is much cheaper
|
||||
self.options[name] = int(range_box.range.slider.value)
|
||||
range_box.range.tag.text = str(int(range_box.range.slider.value))
|
||||
set_button_text(range_box.choice, "Custom")
|
||||
|
||||
def set_button_text(button: MDButton, text: str):
|
||||
button.text.text = text
|
||||
|
||||
def set_value(text: str, range_box: VisualNamedRange):
|
||||
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
|
||||
option.range_end)
|
||||
range_box.range.tag.text = str(int(range_box.range.slider.value))
|
||||
set_button_text(range_box.choice, text)
|
||||
self.options[name] = text.lower()
|
||||
range_box.range.slider.dropdown.dismiss()
|
||||
|
||||
def open_dropdown(button):
|
||||
# for some reason this fixes an issue causing some to not open
|
||||
box.range.slider.dropdown.open()
|
||||
|
||||
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name))
|
||||
if option.default in option.special_range_names:
|
||||
# value can get mismatched in this case
|
||||
box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
|
||||
option.range_end)
|
||||
box.range.tag.text = str(int(box.range.slider.value))
|
||||
box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
|
||||
items = [
|
||||
{
|
||||
"text": choice.title(),
|
||||
"on_release": lambda text=choice.title(): set_value(text, box)
|
||||
}
|
||||
for choice in option.special_range_names
|
||||
]
|
||||
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
|
||||
box.choice.bind(on_release=open_dropdown)
|
||||
self.options[name] = option.default
|
||||
return box
|
||||
|
||||
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
|
||||
text = VisualFreeText(option=option, name=name)
|
||||
|
||||
def set_value(instance):
|
||||
self.options[name] = instance.text
|
||||
|
||||
text.bind(on_text_validate=set_value)
|
||||
return text
|
||||
|
||||
def create_choice(self, option: typing.Type[Choice], name: str):
|
||||
def set_button_text(button: VisualChoice, text: str):
|
||||
button.text.text = text
|
||||
|
||||
def set_value(text, value):
|
||||
set_button_text(main_button, text)
|
||||
self.options[name] = value
|
||||
dropdown.dismiss()
|
||||
|
||||
def open_dropdown(button):
|
||||
# for some reason this fixes an issue causing some to not open
|
||||
dropdown.open()
|
||||
|
||||
default_string = isinstance(option.default, str)
|
||||
main_button = VisualChoice(option=option, name=name)
|
||||
main_button.bind(on_release=open_dropdown)
|
||||
|
||||
items = [
|
||||
{
|
||||
"text": option.get_option_name(choice),
|
||||
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
|
||||
}
|
||||
for choice in option.name_lookup
|
||||
]
|
||||
dropdown = MDDropdownMenu(caller=main_button, items=items)
|
||||
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
|
||||
return main_button
|
||||
|
||||
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
|
||||
def set_button_text(button: MDButton, text: str):
|
||||
for child in button.children:
|
||||
if isinstance(child, MDButtonText):
|
||||
child.text = text
|
||||
|
||||
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
|
||||
text=self.create_free_text(option, name))
|
||||
|
||||
def set_value(instance):
|
||||
set_button_text(box.choice, "Custom")
|
||||
self.options[name] = instance.text
|
||||
|
||||
box.text.bind(on_text_validate=set_value)
|
||||
return box
|
||||
|
||||
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
|
||||
def set_value(instance: MDIconButton):
|
||||
if instance.icon == "checkbox-outline":
|
||||
instance.icon = "checkbox-blank-outline"
|
||||
else:
|
||||
instance.icon = "checkbox-outline"
|
||||
self.options[name] = bool(not self.options[name])
|
||||
|
||||
self.options[name] = bool(option.default)
|
||||
checkbox = VisualToggle(option=option, name=name)
|
||||
checkbox.button.bind(on_release=set_value)
|
||||
|
||||
return checkbox
|
||||
|
||||
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
|
||||
name: str, world: typing.Type[World]):
|
||||
|
||||
valid_keys = sorted(option.valid_keys)
|
||||
if option.verify_item_name:
|
||||
valid_keys += list(world.item_name_to_id.keys())
|
||||
if option.verify_location_name:
|
||||
valid_keys += list(world.location_name_to_id.keys())
|
||||
|
||||
if not issubclass(option, OptionCounter):
|
||||
def apply_changes(button):
|
||||
self.options[name].clear()
|
||||
for list_item in dialog.scrollbox.layout.children:
|
||||
self.options[name].append(getattr(list_item.text, "text"))
|
||||
dialog.dismiss()
|
||||
else:
|
||||
def apply_changes(button):
|
||||
self.options[name].clear()
|
||||
for list_item in dialog.scrollbox.layout.children:
|
||||
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
|
||||
dialog.dismiss()
|
||||
|
||||
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
|
||||
dialog.ids.container.spacing = dp(30)
|
||||
dialog.scrollbox.layout.theme_bg_color = "Custom"
|
||||
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
|
||||
dialog.scrollbox.layout.spacing = dp(5)
|
||||
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
|
||||
|
||||
if name not in self.options:
|
||||
# convert from non-mutable to mutable
|
||||
# We use list syntax even for sets, set behavior is enforced through GUI
|
||||
if issubclass(option, OptionCounter):
|
||||
self.options[name] = deepcopy(option.default)
|
||||
else:
|
||||
self.options[name] = sorted(option.default)
|
||||
|
||||
if issubclass(option, OptionCounter):
|
||||
for value in sorted(self.options[name]):
|
||||
dialog.add_set_item(value, self.options[name].get(value, None))
|
||||
else:
|
||||
for value in sorted(self.options[name]):
|
||||
dialog.add_set_item(value)
|
||||
|
||||
dialog.save.bind(on_release=apply_changes)
|
||||
dialog.open()
|
||||
|
||||
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
|
||||
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
|
||||
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
|
||||
return main_button
|
||||
|
||||
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
|
||||
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
|
||||
|
||||
tooltip = filter_tooltip(option.__doc__)
|
||||
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
|
||||
label_box = MDBoxLayout(orientation="horizontal")
|
||||
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
|
||||
label_anchor.add_widget(option_label)
|
||||
label_box.add_widget(label_anchor)
|
||||
|
||||
option_base.add_widget(label_box)
|
||||
if issubclass(option, NamedRange):
|
||||
option_base.add_widget(self.create_named_range(option, name))
|
||||
elif issubclass(option, Range):
|
||||
option_base.add_widget(self.create_range(option, name))
|
||||
elif issubclass(option, Toggle):
|
||||
option_base.add_widget(self.create_toggle(option, name))
|
||||
elif issubclass(option, TextChoice):
|
||||
option_base.add_widget(self.create_text_choice(option, name))
|
||||
elif issubclass(option, Choice):
|
||||
option_base.add_widget(self.create_choice(option, name))
|
||||
elif issubclass(option, FreeText):
|
||||
option_base.add_widget(self.create_free_text(option, name))
|
||||
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
|
||||
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
|
||||
else:
|
||||
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
|
||||
"Please edit your yaml manually to set this option."))
|
||||
|
||||
if option_can_be_randomized(option):
|
||||
def randomize_option(instance: Widget, value: str):
|
||||
value = value == "down"
|
||||
if value:
|
||||
self.options[name] = "random-" + str(self.options[name])
|
||||
else:
|
||||
self.options[name] = self.options[name].replace("random-", "")
|
||||
if self.options[name].isnumeric():
|
||||
self.options[name] = int(self.options[name])
|
||||
elif self.options[name] in ("True", "False"):
|
||||
self.options[name] = self.options[name] == "True"
|
||||
|
||||
base_object = instance.parent.parent
|
||||
label_object = instance.parent
|
||||
for child in base_object.children:
|
||||
if child is not label_object:
|
||||
child.disabled = value
|
||||
|
||||
default_random = option.default == "random"
|
||||
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
|
||||
state="down" if default_random else "normal")
|
||||
random_toggle.bind(state=randomize_option)
|
||||
label_box.add_widget(random_toggle)
|
||||
if default_random:
|
||||
randomize_option(random_toggle, "down")
|
||||
|
||||
return option_base
|
||||
|
||||
def create_options_panel(self, world_button: WorldButton):
|
||||
self.option_layout.clear_widgets()
|
||||
self.options.clear()
|
||||
cls: typing.Type[World] = world_button.world_cls
|
||||
|
||||
self.current_game = cls.game
|
||||
if not cls.web.options_page:
|
||||
self.current_game = "None"
|
||||
return
|
||||
elif isinstance(cls.web.options_page, str):
|
||||
self.current_game = "None"
|
||||
if validate_url(cls.web.options_page):
|
||||
webbrowser.open(cls.web.options_page)
|
||||
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
world_button.state = "normal"
|
||||
else:
|
||||
# attach onto archipelago.gg and see if we pass
|
||||
new_url = "https://archipelago.gg/" + cls.web.options_page
|
||||
if validate_url(new_url):
|
||||
webbrowser.open(new_url)
|
||||
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
else:
|
||||
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
|
||||
pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
world_button.state = "normal"
|
||||
# else just fall through
|
||||
else:
|
||||
expansion_box = ScrollBox()
|
||||
expansion_box.layout.orientation = "vertical"
|
||||
expansion_box.layout.spacing = dp(3)
|
||||
expansion_box.scroll_type = ["bars"]
|
||||
expansion_box.do_scroll_x = False
|
||||
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
|
||||
groups = {name: [] for name in group_names}
|
||||
for name, option in cls.options_dataclass.type_hints.items():
|
||||
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
|
||||
groups[group].append((name, option))
|
||||
|
||||
for group, options in groups.items():
|
||||
options = [(name, option) for name, option in options
|
||||
if name and option.visibility & Visibility.simple_ui]
|
||||
if not options:
|
||||
continue # Game Options can be empty if every other option is in another group
|
||||
# Can also have an option group of options that should not render on simple ui
|
||||
group_item = MDExpansionPanel(size_hint_y=None)
|
||||
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
|
||||
TrailingPressedIconButton(icon="chevron-right",
|
||||
on_release=lambda x,
|
||||
item=group_item:
|
||||
self.tap_expansion_chevron(
|
||||
item, x)),
|
||||
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||
theme_bg_color="Custom",
|
||||
on_release=lambda x, item=group_item:
|
||||
self.tap_expansion_chevron(item, x)))
|
||||
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
|
||||
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||
padding=[dp(12), dp(100), dp(12), 0],
|
||||
spacing=dp(3))
|
||||
group_item.add_widget(group_header)
|
||||
group_item.add_widget(group_content)
|
||||
group_box = ScrollBox()
|
||||
group_box.layout.orientation = "vertical"
|
||||
group_box.layout.spacing = dp(3)
|
||||
for name, option in options:
|
||||
group_content.add_widget(self.create_option(option, name, cls))
|
||||
expansion_box.layout.add_widget(group_item)
|
||||
self.option_layout.add_widget(expansion_box)
|
||||
self.game_label.text = f"Game: {self.current_game}"
|
||||
|
||||
@staticmethod
|
||||
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
|
||||
if isinstance(chevron, MDListItem):
|
||||
chevron = next((child for child in chevron.ids.trailing_container.children
|
||||
if isinstance(child, TrailingPressedIconButton)), None)
|
||||
panel.open() if not panel.is_open else panel.close()
|
||||
if chevron:
|
||||
panel.set_chevron_down(
|
||||
chevron
|
||||
) if not panel.is_open else panel.set_chevron_up(chevron)
|
||||
|
||||
def build(self):
|
||||
self.set_colors()
|
||||
self.options = {}
|
||||
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
|
||||
self.root = self.container
|
||||
self.main_layout = self.container.ids.main
|
||||
self.scrollbox = self.container.ids.scrollbox
|
||||
|
||||
def world_button_action(world_btn: WorldButton):
|
||||
if self.current_game != world_btn.world_cls.game:
|
||||
old_button = next((button for button in self.scrollbox.layout.children
|
||||
if button.world_cls.game == self.current_game), None)
|
||||
if old_button:
|
||||
old_button.state = "normal"
|
||||
else:
|
||||
world_btn.state = "down"
|
||||
self.create_options_panel(world_btn)
|
||||
|
||||
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
|
||||
if cls.hidden:
|
||||
continue
|
||||
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
|
||||
pos_hint={"x": 0.03, "center_y": 0.5})
|
||||
world_text.text_size = (world_text.width, None)
|
||||
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
|
||||
texture_size=lambda *x, text=world_text: text.setter("height")(text,
|
||||
world_text.texture_size[1]))
|
||||
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
|
||||
radius=(dp(5), dp(5), dp(5), dp(5)))
|
||||
world_button.bind(on_release=world_button_action)
|
||||
world_button.world_cls = cls
|
||||
self.scrollbox.layout.add_widget(world_button)
|
||||
self.main_panel = self.container.ids.player_layout
|
||||
self.player_options = self.container.ids.player_options
|
||||
self.game_label = self.container.ids.game
|
||||
self.name_input = self.container.ids.player_name
|
||||
self.option_layout = self.container.ids.options
|
||||
|
||||
def set_height(instance, value):
|
||||
instance.height = value[1]
|
||||
|
||||
self.game_label.bind(texture_size=set_height)
|
||||
|
||||
# Uncomment to re-enable the Kivy console/live editor
|
||||
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||
# from kivy.modules.console import create_console
|
||||
# from kivy.core.window import Window
|
||||
# create_console(Window, self.container)
|
||||
|
||||
return self.container
|
||||
|
||||
|
||||
def launch():
|
||||
OptionsCreator().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("OptionsCreator")
|
||||
launch()
|
||||
@@ -82,6 +82,9 @@ Currently, the following games are supported:
|
||||
* Paint
|
||||
* Celeste (Open World)
|
||||
* Choo-Choo Charles
|
||||
* APQuest
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
216
Utils.py
216
Utils.py
@@ -22,6 +22,7 @@ from settings import Settings, get_settings
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
from yaml import load, load_all, dump
|
||||
from pathspec import PathSpec, GitIgnoreSpec
|
||||
|
||||
try:
|
||||
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
|
||||
@@ -48,7 +49,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.6.4"
|
||||
__version__ = "0.6.7"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -314,12 +315,8 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = Settings # TODO: remove when removing get_options
|
||||
|
||||
|
||||
def get_options() -> Settings:
|
||||
# TODO: switch to Utils.deprecate after 0.4.4
|
||||
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
||||
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
|
||||
return get_settings()
|
||||
|
||||
|
||||
@@ -391,6 +388,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
|
||||
logging.debug(f"Could not store data package: {e}")
|
||||
|
||||
|
||||
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
|
||||
try:
|
||||
with open(filename) as ignore_file:
|
||||
return GitIgnoreSpec.from_lines(ignore_file)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
||||
import LttPAdjuster
|
||||
adjuster_settings = Namespace()
|
||||
@@ -755,6 +760,11 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
|
||||
res.put(open_filename(*args))
|
||||
|
||||
|
||||
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||
if is_kivy_running():
|
||||
raise RuntimeError("kivy should not be running in multiprocess")
|
||||
res.put(save_filename(*args))
|
||||
|
||||
def _run_for_stdout(*args: str):
|
||||
env = os.environ
|
||||
if "LD_LIBRARY_PATH" in env:
|
||||
@@ -801,8 +811,62 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
||||
except tkinter.TclError:
|
||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None)
|
||||
try:
|
||||
return tkinter.filedialog.askopenfilename(
|
||||
title=title,
|
||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None,
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||
-> typing.Optional[str]:
|
||||
logging.info(f"Opening file save dialog for {title}.")
|
||||
|
||||
if is_linux:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return _run_for_stdout(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
selection = (f"--filename={suggest}",) if suggest else ()
|
||||
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because save_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
if is_macos and is_kivy_running():
|
||||
# on macOS, mixing kivy and tk does not work, so spawn a new process
|
||||
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
|
||||
from multiprocessing import Process, Queue
|
||||
res: "Queue[typing.Optional[str]]" = Queue()
|
||||
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
|
||||
return res.get()
|
||||
try:
|
||||
root = tkinter.Tk()
|
||||
except tkinter.TclError:
|
||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||
root.withdraw()
|
||||
try:
|
||||
return tkinter.filedialog.asksaveasfilename(
|
||||
title=title,
|
||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None,
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||
@@ -850,6 +914,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
if not gui_enabled:
|
||||
if error:
|
||||
logging.error(f"{title}: {text}")
|
||||
else:
|
||||
logging.info(f"{title}: {text}")
|
||||
return
|
||||
|
||||
if is_kivy_running():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
@@ -885,6 +956,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
root.update()
|
||||
|
||||
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
|
||||
|
||||
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
|
||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||
@@ -1004,9 +1078,18 @@ def freeze_support() -> None:
|
||||
_extend_freeze_support()
|
||||
|
||||
|
||||
def visualize_regions(root_region: Region, file_name: str, *,
|
||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||
def visualize_regions(
|
||||
root_region: Region,
|
||||
file_name: str,
|
||||
*,
|
||||
show_entrance_names: bool = False,
|
||||
show_locations: bool = True,
|
||||
show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True,
|
||||
regions_to_highlight: set[Region] | None = None,
|
||||
entrance_highlighting: dict[int, int] | None = None,
|
||||
detail_other_regions: bool = False,
|
||||
auto_assign_colors: bool = False) -> None:
|
||||
"""Visualize the layout of a world as a PlantUML diagram.
|
||||
|
||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||
@@ -1023,6 +1106,13 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
|
||||
your entrances
|
||||
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
|
||||
from root_region.
|
||||
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
|
||||
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
|
||||
not found in the passed-in map
|
||||
|
||||
Example usage in World code:
|
||||
from Utils import visualize_regions
|
||||
@@ -1048,6 +1138,34 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
regions: typing.Deque[Region] = deque((root_region,))
|
||||
multiworld: MultiWorld = root_region.multiworld
|
||||
|
||||
colors_used: set[int] = set()
|
||||
if entrance_highlighting:
|
||||
for color in entrance_highlighting.values():
|
||||
# filter the colors to their most-significant bits to avoid too similar colors
|
||||
colors_used.add(color & 0xF0F0F0)
|
||||
else:
|
||||
# assign an empty dict to not crash later
|
||||
# the parameter is optional for ease of use when you don't care about colors
|
||||
entrance_highlighting = {}
|
||||
|
||||
def select_color(group: int) -> int:
|
||||
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
|
||||
# obvious cyclical color patterns
|
||||
COLOR_INDEX_SPACING: int = 0x357
|
||||
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
|
||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
||||
((new_color_index & 0xF0) << 8) + \
|
||||
((new_color_index & 0xF) << 4)
|
||||
while new_color in colors_used:
|
||||
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
|
||||
# and worlds are unlikely to get to anywhere close to that many entrance groups
|
||||
# intentionally not using multiworld.random to not affect output when debugging with this tool
|
||||
new_color_index += COLOR_INDEX_SPACING
|
||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
||||
((new_color_index & 0xF0) << 8) + \
|
||||
((new_color_index & 0xF) << 4)
|
||||
return new_color
|
||||
|
||||
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
|
||||
name = obj.name
|
||||
if isinstance(obj, Item):
|
||||
@@ -1067,18 +1185,28 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
|
||||
def visualize_exits(region: Region) -> None:
|
||||
for exit_ in region.exits:
|
||||
color_code: str = ""
|
||||
if exit_.randomization_group in entrance_highlighting:
|
||||
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
|
||||
if exit_.connected_region:
|
||||
if show_entrance_names:
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
|
||||
else:
|
||||
try:
|
||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
|
||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
|
||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
|
||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
|
||||
except ValueError:
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
|
||||
else:
|
||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
|
||||
for entrance in region.entrances:
|
||||
color_code: str = ""
|
||||
if entrance.randomization_group in entrance_highlighting:
|
||||
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
|
||||
if not entrance.parent_region:
|
||||
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
|
||||
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
|
||||
|
||||
def visualize_locations(region: Region) -> None:
|
||||
any_lock = any(location.locked for location in region.locations)
|
||||
@@ -1099,9 +1227,27 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
|
||||
uml.append("package \"other regions\" <<Cloud>> {")
|
||||
for region in other_regions:
|
||||
uml.append(f"class \"{fmt(region)}\"")
|
||||
if detail_other_regions:
|
||||
visualize_region(region)
|
||||
else:
|
||||
uml.append(f"class \"{fmt(region)}\"")
|
||||
uml.append("}")
|
||||
|
||||
if auto_assign_colors:
|
||||
all_entrances: list[Entrance] = []
|
||||
for region in multiworld.get_regions(root_region.player):
|
||||
all_entrances.extend(region.entrances)
|
||||
all_entrances.extend(region.exits)
|
||||
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
|
||||
for group in all_groups:
|
||||
if group not in entrance_highlighting:
|
||||
if len(colors_used) >= 0x1000:
|
||||
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
|
||||
break
|
||||
new_color: int = select_color(group)
|
||||
entrance_highlighting[group] = new_color
|
||||
colors_used.add(new_color)
|
||||
|
||||
uml.append("@startuml")
|
||||
uml.append("hide circle")
|
||||
uml.append("hide empty members")
|
||||
@@ -1112,7 +1258,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
seen.add(current_region)
|
||||
visualize_region(current_region)
|
||||
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
|
||||
if show_other_regions:
|
||||
if show_other_regions or detail_other_regions:
|
||||
visualize_other_regions()
|
||||
uml.append("@enduml")
|
||||
|
||||
@@ -1176,3 +1322,35 @@ class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
||||
|
||||
|
||||
def get_full_typename(t: type) -> str:
|
||||
"""Returns the full qualified name of a type, including its module (if not builtins)."""
|
||||
module = t.__module__
|
||||
if module and module != "builtins":
|
||||
return f"{module}.{t.__qualname__}"
|
||||
return t.__qualname__
|
||||
|
||||
|
||||
def get_all_causes(ex: Exception) -> str:
|
||||
"""Return a string describing the recursive causes of this exception.
|
||||
|
||||
:param ex: The exception to be described.
|
||||
:return A multiline string starting with the initial exception on the first line and each resulting exception
|
||||
on subsequent lines with progressive indentation.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
Exception: Invalid value 'bad'.
|
||||
Which caused: Options.OptionError: Error generating option
|
||||
Which caused: ValueError: File bad.yaml is invalid.
|
||||
```
|
||||
"""
|
||||
cause = ex
|
||||
causes = [f"{get_full_typename(type(ex))}: {ex}"]
|
||||
while cause := cause.__cause__:
|
||||
causes.append(f"{get_full_typename(type(cause))}: {cause}")
|
||||
top = causes[-1]
|
||||
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
|
||||
return f"{top}{others}"
|
||||
|
||||
@@ -20,7 +20,8 @@ if typing.TYPE_CHECKING:
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
if not os.path.exists(configpath):
|
||||
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
# WebHost
|
||||
|
||||
## Asset License
|
||||
|
||||
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
|
||||
level LICENSE.
|
||||
See individual LICENSE files in `./static/static/**`.
|
||||
|
||||
You are only allowed to use them for personal use, testing and development.
|
||||
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
|
||||
and do not promote it publicly. Alternatively replace or remove the assets.
|
||||
|
||||
## Contribution Guidelines
|
||||
**Thank you for your interest in contributing to the Archipelago website!**
|
||||
Much of the content on the website is generated automatically, but there are some things
|
||||
that need a personal touch. For those things, we rely on contributions from both the core
|
||||
team and the community. The current primary maintainer of the website is Farrak Kilhn.
|
||||
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
|
||||
|
||||
### Small Changes
|
||||
Little changes like adding a button or a couple new select elements are perfectly fine.
|
||||
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
|
||||
you build a new page which needs two side by side tables, and you need to write a CSS file
|
||||
specific to your page, that is perfectly reasonable.
|
||||
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
|
||||
Design changes have to fit the overall design.
|
||||
|
||||
### Content Additions
|
||||
Once you develop a new feature or add new content the website, make a pull request. It will
|
||||
be reviewed by the community and there will probably be some discussion around it. Depending
|
||||
on the size of the feature, and if new styles are required, there may be an additional step
|
||||
before the PR is accepted wherein Farrak works with the designer to implement styles.
|
||||
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
|
||||
|
||||
### Restrictions on Style Changes
|
||||
A professional designer is paid to develop the styles and assets for the Archipelago website.
|
||||
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
|
||||
change site styles are rejected. Please note this applies to code which changes the overall
|
||||
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
|
||||
behind these restrictions is to maintain a curated feel for the design of the site. If
|
||||
any PR affects the overall feel of the site but includes additive changes, there will
|
||||
likely be a conversation about how to implement those changes without compromising the
|
||||
curated site style. It is therefore worth noting there are a couple files which, if
|
||||
changed in your pull request, will cause it to draw additional scrutiny.
|
||||
|
||||
These closely guarded files are:
|
||||
- `globalStyles.css`
|
||||
- `islandFooter.css`
|
||||
- `landing.css`
|
||||
- `markdown.css`
|
||||
- `tooltip.css`
|
||||
|
||||
### Site Themes
|
||||
There are several themes available for game pages. It is possible to request a new theme in
|
||||
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
|
||||
are not free, and take some time to create. Farrak works closely with the designer to implement
|
||||
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
|
||||
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
|
||||
good chance it will become a reality.
|
||||
See also [docs/style.md](/docs/style.md) for the style guide.
|
||||
|
||||
@@ -23,6 +23,17 @@ app.jinja_env.filters['any'] = any
|
||||
app.jinja_env.filters['all'] = all
|
||||
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
|
||||
|
||||
# overwrites of flask default config
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
app.config["SESSION_PERMANENT"] = True
|
||||
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
|
||||
|
||||
# custom config
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
||||
@@ -30,19 +41,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
# archipelago.gg uses gunicorn + nginx; ignoring this option
|
||||
|
||||
@@ -58,6 +58,12 @@ class PlayerLocationsTotal(TypedDict):
|
||||
total_locations: int
|
||||
|
||||
|
||||
class PlayerGame(TypedDict):
|
||||
team: int
|
||||
player: int
|
||||
game: str
|
||||
|
||||
|
||||
@api_endpoints.route("/tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=60)
|
||||
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
@@ -80,7 +86,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
"""Slot aliases of all players."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||
player_aliases.append(
|
||||
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||
|
||||
player_items_received: list[PlayerItemsReceived] = []
|
||||
"""Items received by each player."""
|
||||
@@ -94,7 +101,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_checks_done.append(
|
||||
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||
{"team": team, "player": player,
|
||||
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||
|
||||
total_checks_done: list[TeamTotalChecks] = [
|
||||
{"team": team, "checks_done": checks_done}
|
||||
@@ -144,7 +152,8 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
"""The current client status for each player."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||
player_status.append(
|
||||
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||
|
||||
return {
|
||||
"aliases": player_aliases,
|
||||
@@ -207,12 +216,20 @@ def static_tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||
player_locations_total.append(
|
||||
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
||||
|
||||
player_game: list[PlayerGame] = []
|
||||
"""The played game per player slot."""
|
||||
for team, players in all_players.items():
|
||||
for player in players:
|
||||
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
|
||||
|
||||
return {
|
||||
"groups": groups,
|
||||
"datapackage": tracker_data._multidata["datapackage"],
|
||||
"player_locations_total": player_locations_total,
|
||||
"player_game": player_game,
|
||||
}
|
||||
|
||||
|
||||
# It should be exceedingly rare that slot data is needed, so it's separated out.
|
||||
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
|
||||
@cache.memoize(timeout=300)
|
||||
|
||||
@@ -89,19 +89,24 @@ class WebHostContext(Context):
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
async def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while not self.exit_event.is_set():
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
for command in commands:
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
del commands
|
||||
time.sleep(5)
|
||||
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
|
||||
try:
|
||||
await asyncio.wait_for(self.exit_event.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def _process_db_commands(self, cmdprocessor):
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
for command in commands:
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
|
||||
@db_session
|
||||
def load(self, room_id: int):
|
||||
@@ -156,9 +161,9 @@ class WebHostContext(Context):
|
||||
with db_session:
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||
self.set_save(restricted_loads(savegame_data))
|
||||
self._start_async_saving(atexit_save=False)
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
asyncio.create_task(self.listen_to_db_commands())
|
||||
|
||||
@db_session
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
@@ -229,6 +234,17 @@ def set_up_logging(room_id) -> logging.Logger:
|
||||
return logger
|
||||
|
||||
|
||||
def tear_down_logging(room_id):
|
||||
"""Close logging handling for a room."""
|
||||
logger_name = f"RoomLogger {room_id}"
|
||||
if logger_name in logging.Logger.manager.loggerDict:
|
||||
logger = logging.getLogger(logger_name)
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
del logging.Logger.manager.loggerDict[logger_name]
|
||||
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
@@ -325,7 +341,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
ctx._save(True)
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
@@ -336,19 +352,25 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
raise
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
ctx._save(True)
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
finally:
|
||||
try:
|
||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||
|
||||
if ctx.server and hasattr(ctx.server, "ws_server"):
|
||||
ctx.server.ws_server.close()
|
||||
await ctx.server.ws_server.wait_closed()
|
||||
|
||||
with db_session:
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - \
|
||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
del room
|
||||
tear_down_logging(room_id)
|
||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||
finally:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
import os
|
||||
import warnings
|
||||
from enum import StrEnum
|
||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
@@ -13,11 +15,25 @@ from .markdown import render_markdown
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
from Utils import title_sorted
|
||||
|
||||
class WebWorldTheme(StrEnum):
|
||||
DIRT = "dirt"
|
||||
GRASS = "grass"
|
||||
GRASS_FLOWERS = "grassFlowers"
|
||||
ICE = "ice"
|
||||
JUNGLE = "jungle"
|
||||
OCEAN = "ocean"
|
||||
PARTY_TIME = "partyTime"
|
||||
STONE = "stone"
|
||||
|
||||
def get_world_theme(game_name: str) -> str:
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
if game_name not in AutoWorldRegister.world_types:
|
||||
return "grass"
|
||||
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
|
||||
available_themes = [theme.value for theme in WebWorldTheme]
|
||||
if chosen_theme not in available_themes:
|
||||
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
|
||||
return "grass"
|
||||
return chosen_theme
|
||||
|
||||
|
||||
def get_visible_worlds() -> dict[str, type(World)]:
|
||||
@@ -112,8 +128,13 @@ def tutorial_landing():
|
||||
"authors": tutorial.authors,
|
||||
"language": tutorial.language
|
||||
}
|
||||
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
||||
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
||||
|
||||
worlds = dict(
|
||||
title_sorted(
|
||||
worlds.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game
|
||||
)
|
||||
)
|
||||
|
||||
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from Utils import local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .generate import get_meta
|
||||
from .misc import get_world_theme
|
||||
|
||||
|
||||
def create() -> None:
|
||||
@@ -22,12 +23,6 @@ def create() -> None:
|
||||
Options.generate_yaml_templates(yaml_folder)
|
||||
|
||||
|
||||
def get_world_theme(game_name: str) -> str:
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
||||
world = AutoWorldRegister.world_types[world_name]
|
||||
if world.hidden or world.web.options_page is False:
|
||||
|
||||
@@ -23,7 +23,7 @@ players to rely upon each other to complete their game.
|
||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||
Here is a list of our [Supported Games](/games).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
|
||||
|
||||
## How do I get started?
|
||||
|
||||
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
|
||||
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
|
||||
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
|
||||
including multiple games, and hosting multiworlds on the website for ease and convenience.
|
||||
|
||||
@@ -57,7 +57,7 @@ their multiworld.
|
||||
|
||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
||||
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
||||
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
|
||||
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
|
||||
|
||||
## What happens if an item is placed somewhere it is impossible to get?
|
||||
|
||||
|
||||
@@ -241,12 +241,9 @@ input[type="checkbox"]{
|
||||
}
|
||||
|
||||
/* Hidden items */
|
||||
.hidden-class:not(:has(img.acquired)){
|
||||
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
|
||||
display: none;
|
||||
}
|
||||
.hidden-item:not(.acquired){
|
||||
display:none;
|
||||
}
|
||||
|
||||
/* Keys */
|
||||
#keys ol, #keys ul{
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="named-range-container">
|
||||
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% if option.default not in option.special_range_names.values() %}
|
||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
||||
{% endif %}
|
||||
{% for key, val in option.special_range_names.items() %}
|
||||
{% if option.default == val %}
|
||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||
@@ -94,6 +97,9 @@
|
||||
<div class="text-choice-container">
|
||||
<div class="text-choice-wrapper">
|
||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% if option.default not in option.options.values() %}
|
||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
||||
{% endif %}
|
||||
{% for id, name in option.name_lookup.items()|sort %}
|
||||
{% if name != "random" %}
|
||||
{% if option.default == id %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -959,7 +959,7 @@ if "Timespinner" in network_data_package["games"]:
|
||||
|
||||
timespinner_location_ids = {
|
||||
"Present": list(range(1337000, 1337085)),
|
||||
"Past": list(range(1337086, 1337175)),
|
||||
"Past": list(range(1337086, 1337157)) + list(range(1337159, 1337175)),
|
||||
"Ancient Pyramid": [
|
||||
1337236,
|
||||
1337246, 1337247, 1337248, 1337249]
|
||||
@@ -1228,7 +1228,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
||||
def render_Starcraft2_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
||||
SC2WOL_ITEM_ID_OFFSET = 1000
|
||||
SC2HOTS_ITEM_ID_OFFSET = 2000
|
||||
SC2LOTV_ITEM_ID_OFFSET = 2000
|
||||
SC2LOTV_ITEM_ID_OFFSET = 3000
|
||||
SC2_KEY_ITEM_ID_OFFSET = 4000
|
||||
NCO_LOCATION_ID_LOW = 20004500
|
||||
NCO_LOCATION_ID_HIGH = NCO_LOCATION_ID_LOW + 1000
|
||||
|
||||
@@ -289,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
|
||||
2
ci-requirements.txt
Normal file
2
ci-requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pytest>=9.0.1,<10 # this includes subtests support
|
||||
pytest-xdist>=3.8.0
|
||||
13
data/GLOBAL.apignore
Normal file
13
data/GLOBAL.apignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# This file specifies patterns that are ignored by default for any world built with the "Build APWorlds" component.
|
||||
# These patterns can be overriden by a world-specific .apignore using !-prefixed patterns for negation.
|
||||
|
||||
# Auto-created folders
|
||||
__MACOSX
|
||||
.DS_Store
|
||||
__pycache__
|
||||
|
||||
# Unneeded files
|
||||
/archipelago.json
|
||||
/.apignore
|
||||
/.git
|
||||
/.gitignore
|
||||
@@ -224,6 +224,7 @@
|
||||
height: self.content.texture_size[1] + 80
|
||||
<ScrollBox>:
|
||||
layout: layout
|
||||
box_height: dp(100)
|
||||
bar_width: "12dp"
|
||||
scroll_wheel_distance: 40
|
||||
do_scroll_x: False
|
||||
@@ -234,4 +235,11 @@
|
||||
orientation: "vertical"
|
||||
spacing: 10
|
||||
size_hint_y: None
|
||||
height: self.minimum_height
|
||||
height: max(self.minimum_height, root.box_height)
|
||||
|
||||
<MessageBoxLabel>:
|
||||
valign: "middle"
|
||||
halign: "center"
|
||||
text_size: self.width, None
|
||||
height: self.texture_size[1]
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
name: Player{number}
|
||||
|
||||
# Used to describe your yaml. Useful if you have multiple files.
|
||||
description: {{ yaml_dump("Default %s Template" % game) }}
|
||||
description: {{ yaml_dump("%s Preset for %s" % (preset_name, game)) if preset_name else yaml_dump("Default %s Template" % game) }}
|
||||
|
||||
game: {{ yaml_dump(game) }}
|
||||
requires:
|
||||
@@ -38,11 +38,11 @@ requires:
|
||||
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
|
||||
{%- endif %}
|
||||
|
||||
{%- macro range_option(option) %}
|
||||
{%- macro range_option(option, option_val) %}
|
||||
# You can define additional values between the minimum and maximum values.
|
||||
# Minimum value is {{ option.range_start }}
|
||||
# Maximum value is {{ option.range_end }}
|
||||
{%- set data, notes = dictify_range(option) %}
|
||||
{%- set data, notes = dictify_range(option, option_val) %}
|
||||
{%- for entry, default in data.items() %}
|
||||
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
||||
{%- endfor -%}
|
||||
@@ -56,6 +56,10 @@ requires:
|
||||
|
||||
{%- for option_key, option in group_options.items() %}
|
||||
{{ option_key }}:
|
||||
{%- set option_val = option.default %}
|
||||
{%- if option_key in preset %}
|
||||
{%- set option_val = preset[option_key] %}
|
||||
{%- endif -%}
|
||||
{%- if option.__doc__ %}
|
||||
# {{ cleandoc(option.__doc__)
|
||||
| trim
|
||||
@@ -69,25 +73,25 @@ requires:
|
||||
{%- endif -%}
|
||||
|
||||
{%- if option.range_start is defined and option.range_start is number %}
|
||||
{{- range_option(option) -}}
|
||||
{{- range_option(option, option_val) -}}
|
||||
|
||||
{%- elif option.options -%}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option_val or sub_option_name == option_val %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if option.name_lookup[option.default] not in option.options %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
|
||||
{%- if option.name_lookup[option_val] not in option.options and option_val not in option.options %}
|
||||
{{ yaml_dump(option_val) }}: 50
|
||||
{%- endif -%}
|
||||
|
||||
{%- elif option.default is string %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
{%- elif option_val is string %}
|
||||
{{ yaml_dump(option_val) }}: 50
|
||||
|
||||
{%- elif option.default is iterable and option.default is not mapping %}
|
||||
{{ option.default | list }}
|
||||
{%- elif option_val is iterable and option_val is not mapping %}
|
||||
{{ option_val | list }}
|
||||
|
||||
{%- else %}
|
||||
{{ yaml_dump(option.default) | indent(4, first=false) }}
|
||||
{{ yaml_dump(option_val) | indent(4, first=false) }}
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
|
||||
174
data/optionscreator.kv
Normal file
174
data/optionscreator.kv
Normal file
@@ -0,0 +1,174 @@
|
||||
<VisualRange>:
|
||||
id: this
|
||||
spacing: 15
|
||||
orientation: "horizontal"
|
||||
slider: slider
|
||||
tag: tag
|
||||
MDLabel:
|
||||
id: tag
|
||||
text: str(this.option.default) if not isinstance(this.option.default, str) else str(this.option.range_start)
|
||||
MDSlider:
|
||||
id: slider
|
||||
min: this.option.range_start
|
||||
max: this.option.range_end
|
||||
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if not isinstance(this.option.default, str) else this.option.range_start
|
||||
step: 1
|
||||
step_point_size: 0
|
||||
MDSliderHandle:
|
||||
|
||||
MDSliderValueLabel:
|
||||
|
||||
<VisualChoice>:
|
||||
id: this
|
||||
text: text
|
||||
MDButtonText:
|
||||
id: text
|
||||
text: this.option.get_option_name(this.option.default if not isinstance(this.option.default, str) else list(this.option.options.values())[0])
|
||||
theme_text_color: "Primary"
|
||||
|
||||
<VisualNamedRange>:
|
||||
id: this
|
||||
orientation: "horizontal"
|
||||
spacing: "10dp"
|
||||
padding: (0, 0, "10dp", 0)
|
||||
choice: choice
|
||||
|
||||
MDButton:
|
||||
id: choice
|
||||
text: text
|
||||
MDButtonText:
|
||||
id: text
|
||||
text: this.option.default.title() if this.option.default in this.option.special_range_names else "Custom"
|
||||
|
||||
<VisualFreeText>:
|
||||
multiline: False
|
||||
font_size: "15sp"
|
||||
text: self.option.default if isinstance(self.option.default, str) else ""
|
||||
theme_height: "Custom"
|
||||
height: "30dp"
|
||||
|
||||
|
||||
<VisualTextChoice>:
|
||||
id: this
|
||||
orientation: "horizontal"
|
||||
spacing: "5dp"
|
||||
padding: (0, 0, "10dp", 0)
|
||||
|
||||
<VisualToggle>:
|
||||
id: this
|
||||
button: button
|
||||
MDIconButton:
|
||||
id: button
|
||||
icon: "checkbox-outline" if this.option.default else "checkbox-blank-outline"
|
||||
|
||||
<VisualListSetEntry@ResizableTextField>:
|
||||
height: "20dp"
|
||||
|
||||
<CounterItemValue>:
|
||||
height: "30dp"
|
||||
|
||||
<VisualListSetCounter>:
|
||||
id: this
|
||||
scrollbox: scrollbox
|
||||
add: add
|
||||
save: save
|
||||
input: input
|
||||
focus_behavior: False
|
||||
|
||||
MDDialogHeadlineText:
|
||||
text: getattr(this.option, "display_name", this.name)
|
||||
|
||||
MDDialogSupportingText:
|
||||
text: "Add or Remove Entries"
|
||||
|
||||
MDDialogContentContainer:
|
||||
orientation: "vertical"
|
||||
spacing: 10
|
||||
|
||||
MDBoxLayout:
|
||||
orientation: "horizontal"
|
||||
VisualListSetEntry:
|
||||
id: input
|
||||
height: "20dp"
|
||||
|
||||
MDIconButton:
|
||||
id: add
|
||||
icon: "plus"
|
||||
theme_height: "Custom"
|
||||
height: "20dp"
|
||||
on_press: root.validate_add(input)
|
||||
|
||||
ScrollBox:
|
||||
id: scrollbox
|
||||
size_hint_y: None
|
||||
adapt_minimum: False
|
||||
|
||||
MDButton:
|
||||
id: save
|
||||
MDButtonText:
|
||||
text: "Save Changes"
|
||||
|
||||
ContainerLayout:
|
||||
md_bg_color: app.theme_cls.backgroundColor
|
||||
|
||||
MainLayout:
|
||||
id: main
|
||||
cols: 3
|
||||
padding: 3, 5, 0, 3
|
||||
spacing: "2dp"
|
||||
|
||||
ScrollBox:
|
||||
id: scrollbox
|
||||
size_hint_x: None
|
||||
width: "150dp"
|
||||
|
||||
MDDivider:
|
||||
orientation: "vertical"
|
||||
width: "4dp"
|
||||
|
||||
MainLayout:
|
||||
id: player_layout
|
||||
rows: 2
|
||||
spacing: "20dp"
|
||||
|
||||
MDBoxLayout:
|
||||
id: player_options
|
||||
orientation: "horizontal"
|
||||
height: "75dp"
|
||||
size_hint_y: None
|
||||
padding: ["10dp", "30dp", "10dp", 0]
|
||||
spacing: "10dp"
|
||||
|
||||
ResizableTextField:
|
||||
id: player_name
|
||||
multiline: False
|
||||
|
||||
MDTextFieldHintText:
|
||||
text: "Player Name"
|
||||
|
||||
MDTextFieldMaxLengthText:
|
||||
max_text_length: 16
|
||||
|
||||
MDBoxLayout:
|
||||
orientation: "vertical"
|
||||
spacing: "15dp"
|
||||
|
||||
MDLabel:
|
||||
id: game
|
||||
text: "Game: None"
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
|
||||
MDButton:
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
on_press: app.export_options(self)
|
||||
theme_width: "Custom"
|
||||
size_hint_y: 1
|
||||
size_hint_x: 1
|
||||
|
||||
MDButtonText:
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
text: "Export Options"
|
||||
|
||||
MainLayout:
|
||||
cols: 1
|
||||
id: options
|
||||
@@ -8,3 +8,7 @@ SELFLAUNCH: false
|
||||
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
|
||||
# Set as your local IP (192.168.x.x) to serve over LAN.
|
||||
HOST_ADDRESS: localhost
|
||||
|
||||
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
|
||||
# the proprietary assets in WebHostLib
|
||||
#ASSET_RIGHTS: false
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
# A Link to the Past
|
||||
/worlds/alttp/ @Berserker66
|
||||
|
||||
# APQuest
|
||||
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
|
||||
/worlds/apquest/ @NewSoupVi
|
||||
|
||||
# Sudoku (APSudoku)
|
||||
/worlds/apsudoku/ @EmilyV99
|
||||
|
||||
@@ -66,6 +70,9 @@
|
||||
# DOOM II
|
||||
/worlds/doom_ii/ @Daivuk @KScl
|
||||
|
||||
# EarthBound
|
||||
/worlds/earthbound/ @PinkSwitch
|
||||
|
||||
# Factorio
|
||||
/worlds/factorio/ @Berserker66
|
||||
|
||||
@@ -172,8 +179,12 @@
|
||||
# Sonic Adventure 2 Battle
|
||||
/worlds/sa2b/ @PoryGone @RaspberrySpace
|
||||
|
||||
# Satisfactory
|
||||
/worlds/satisfactory/ @Jarno458 @budak7273
|
||||
|
||||
# Starcraft 2
|
||||
/worlds/sc2/ @Ziktofel
|
||||
# Note: @Ziktofel acts as a mentor
|
||||
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
|
||||
|
||||
# Super Metroid
|
||||
/worlds/sm/ @lordlou
|
||||
|
||||
@@ -17,7 +17,8 @@ it will not be detailed here.
|
||||
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
||||
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
||||
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
|
||||
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
|
||||
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. Additional help with specific game
|
||||
engines and rom formats can be found in the #ap-modding-help channel in the [Discord](https://archipelago.gg/discord).
|
||||
|
||||
### Hard Requirements
|
||||
|
||||
@@ -139,8 +140,8 @@ if possible.
|
||||
|
||||
* An implementation of
|
||||
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
|
||||
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
|
||||
filler items.
|
||||
* By default, this function chooses any item name from `item_name_to_id`, which may include items you consider
|
||||
"non-repeatable".
|
||||
* An `options_dataclass` defining the options players have available to them
|
||||
* This should be accompanied by a type hint for `options` with the same class name
|
||||
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
|
||||
|
||||
@@ -41,16 +41,18 @@ There are also the following optional fields:
|
||||
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
||||
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
||||
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
|
||||
["Build apworlds" launcher component](#build-apworlds-launcher-component),
|
||||
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
|
||||
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
||||
|
||||
### "Build apworlds" Launcher Component
|
||||
### "Build APWorlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
|
||||
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
|
||||
and add `archipelago.json` manifest files to them.
|
||||
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
||||
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
||||
`version` and `compatible_version`.
|
||||
`version` and `compatible_version`.
|
||||
The component can also be called from the command line to allow for specifying a certain list of worlds to build.
|
||||
For example, running `Launcher.py "Build APWorlds" -- "Game Name"` will build only the game called `Game Name`.
|
||||
|
||||
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
|
||||
So, a world folder with an `archipelago.json` that looks like this:
|
||||
@@ -79,10 +81,26 @@ will be packaged into an `.apworld` with a manifest file inside of it that looks
|
||||
|
||||
This is the recommended workflow for packaging your world to an `.apworld`.
|
||||
|
||||
## Extra Data
|
||||
### .apignore Exclusions
|
||||
|
||||
The zip can contain arbitrary files in addition what was specified above.
|
||||
By default, any additional files inside of the world folder will be packaged into the resulting `.apworld` archive and
|
||||
can then be read by the world. However, if there are any other files that aren't needed in the resulting `.apworld`, you
|
||||
can automatically prevent the build component from including them by specifying them in a file called `.apignore` inside
|
||||
the root of the world folder.
|
||||
|
||||
The `.apignore` file selects files in the same way as the `.gitignore` format with patterns separated by line describing
|
||||
which files to ignore. For example, an `.apignore` like this:
|
||||
|
||||
```gitignore
|
||||
*.iso
|
||||
scripts/
|
||||
!scripts/needed.py
|
||||
```
|
||||
|
||||
would ignore any `.iso` files and anything in the scripts folder except for `scripts/needed.py`.
|
||||
|
||||
Some exclusions are made by default for all worlds such as `__pycache__` folders. These are listed in the
|
||||
`GLOBAL.apignore` file inside of the `data` directory.
|
||||
|
||||
## Caveats
|
||||
|
||||
|
||||
@@ -6,6 +6,49 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
|
||||
|
||||
---
|
||||
|
||||
### I've never added a game to Archipelago before. Should I start with the APWorld or the game client?
|
||||
|
||||
Strictly speaking, this is a false dichotomy: we do *not* recommend doing 100% of client work before the APWorld,
|
||||
or 100% of APWorld work before the client. It's important to iterate on both parts and test them together.
|
||||
However, the early iterations tend to be very similar for most games,
|
||||
so the typical recommendation for first-time AP developers is:
|
||||
|
||||
- Start with a proof-of-concept for [the game client](adding%20games.md#client)
|
||||
- Figure out how to interface with the game. Whether that means "modding" the game, or patching a ROM file,
|
||||
or developing a separate client program that edits the game's memory, or some other technique.
|
||||
- Figure out how to give items and detect locations in the actual game. Not every item and location,
|
||||
just one of each major type (e.g. opening a chest vs completing a sidequest) to prove all the items and locations
|
||||
you want can actually be implemented.
|
||||
- Figure out how to make a websocket connection to an AP server, possibly using a client library (see [Network Protocol](<network%20protocol.md>).
|
||||
To make absolutely sure this part works, you may want to test the connection by generating a multiworld
|
||||
with a different game, then making your client temporarily pretend to be that other game.
|
||||
- Next, make a "trivial" APWorld, i.e. an APWorld that always generates the same items and locations
|
||||
- If you've never done this before, likely the fastest approach is to copy-paste [APQuest](<../worlds/apquest>), and read the many
|
||||
comments in there until you understand how to edit the items and locations.
|
||||
- Then you can do your first "end-to-end test": generate a multiworld using your APWorld, [run a local server](<running%20from%20source.md>)
|
||||
to host it, connect to that local server from your game client, actually check a location in the game,
|
||||
and finally make sure the client successfully sent that location check to the AP server
|
||||
as well as received an item from it.
|
||||
|
||||
That's about where general recommendations end. What you should do next will depend entirely on your game
|
||||
(e.g. implement more items, write down logic rules, add client features, prototype a tracker, etc).
|
||||
If you're not sure, then this would be a good time to re-read [Adding Games](<adding%20games.md>), and [World API](<world%20api.md>).
|
||||
|
||||
There are a few assumptions in this recommendation worth stating explicitly, namely:
|
||||
|
||||
- If something you want to do is infeasible, you want to find out that it's infeasible as soon as possible, before
|
||||
you write a bunch of code assuming it could be done. That's why we recommend starting with the game client.
|
||||
- Getting an APWorld to generate whatever items/locations you want is always feasible, since items/locations are
|
||||
little more than id numbers and name strings during generation.
|
||||
- You generally want to get to an "end-to-end playable" prototype quickly. On top of all the technical challenges these
|
||||
docs describe, it's also important to check that a randomizer is *fun to play*, and figure out what features would be
|
||||
essential for a public release.
|
||||
- A first-time world developer may or may not be deeply familiar with Archipelago, but they're almost certainly familiar
|
||||
with the game they want to randomize. So judging whether your game client is working correctly might be significantly
|
||||
easier than judging if your APWorld is working.
|
||||
|
||||
---
|
||||
|
||||
### My game has a restrictive start that leads to fill errors
|
||||
|
||||
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
|
||||
@@ -140,3 +183,58 @@ So when the game itself does not follow this assumption, the options are:
|
||||
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
|
||||
- For locations, this may require game changes to remove the vanilla item if it affects logic
|
||||
- Decide that resetting the save file is part of the game's logic, and warn players about that
|
||||
|
||||
---
|
||||
|
||||
### What are "local" vs "remote" items, and what are the pros and cons of each?
|
||||
|
||||
First off, these terms can be misleading. Since the whole point of a multi-game multiworld randomizer is that some items
|
||||
are going to be placed in other slots (unless there's only one slot), the choice isn't really "local vs remote";
|
||||
it's "mixed local/remote vs all remote". You have to get "remote items" working to be an AP implementation at all, and
|
||||
it's often simpler to handle every item/location the same way, so you generally shouldn't worry about "local items"
|
||||
until you've finished more critical features.
|
||||
|
||||
Next, "local" and "remote" items confusingly refer to multiple concepts, so it's important to clearly separate them:
|
||||
|
||||
- Whether an item happens to get placed in the same slot it originates from, or a different slot. I'll call these
|
||||
"locally placed" and "remotely placed" items.
|
||||
- Whether an AP client implements location checking for locally placed items by skipping the usual AP server roundtrip
|
||||
(i.e. sending [LocationChecks](<network%20protocol.md#locationchecks>)
|
||||
then receiving [ReceivedItems](<network%20protocol.md#receiveditems>)
|
||||
) and directly giving the item to the player, or by doing the AP server roundtrip regardless. I'll call these
|
||||
"locally implemented" items and "remotely implemented" items.
|
||||
- Locally implementing items requires the AP client to know what the locally placed items were without asking an AP
|
||||
server (or else you'd effectively be doing remote items with extra steps). Typically, it gets that information from
|
||||
a patch file, which is one reason why games that already need a patch file are more likely to choose local items.
|
||||
- If items are remotely implemented, the AP client can use [location scouts](<network%20protocol.md#LocationScouts>)
|
||||
to learn what items are placed on what locations. Features that require this information are sometimes mistakenly
|
||||
assumed to require locally implemented items, but location scouts work just as well as patch file data.
|
||||
- [The `items_handling` bitflags in the Connect packet](<network%20protocol.md#items_handling-flags>).
|
||||
AP clients with remotely implemented items will typically set all three flags, including "from your own world".
|
||||
Clients with locally implemented items might set only the "from other worlds" flag.
|
||||
- Whether a local items client sets the "starting inventory" flag likely depends on other details. For example, if a ROM
|
||||
is being patched, and starting inventory can be added to that patch, then it makes sense to leave the flag unset.
|
||||
|
||||
When people talk about "local vs remote items" as a choice that world devs have to make, they mean deciding whether
|
||||
your client will locally or remotely implement the items which happen to be locally placed (or make both
|
||||
implementations, or let the player choose an implementation).
|
||||
|
||||
Theoretically, the biggest benefit of "local items" is that it allows a solo (single slot) multiworld to be played
|
||||
entirely offline, with no AP server, from start to finish. This is similar to a "standalone"/non-AP randomizer,
|
||||
except that you still get AP's player options, generation, etc. for free.
|
||||
For some games, there are also technical constraints that make certain items easier to implement locally,
|
||||
or less glitchy when implemented locally, as long as you're okay with never allowing these items to be placed remotely
|
||||
(or offering the player even more options).
|
||||
|
||||
The main downside (besides more implementation work) is that "local items" can't support "same slot co-op".
|
||||
That's when two players on two different machines connect to the same slot and play together.
|
||||
This only works if both players receive all the items for that slot, including ones found by the other player,
|
||||
which requires those items to be implemented remotely so the AP server can send them to all of that slot's clients.
|
||||
|
||||
So to recap:
|
||||
|
||||
- (All) remote items is often the simplest choice, since you have to implement remote items anyway.
|
||||
- Remote items enable same slot co-op.
|
||||
- Local items enable solo offline play.
|
||||
- If you want to support both solo offline play and same slot co-op,
|
||||
you might need to expose local vs remote items as an option to the player.
|
||||
|
||||
@@ -20,7 +20,7 @@ game contributions:
|
||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||
pushing.
|
||||
You can turn them on here:
|
||||

|
||||

|
||||
|
||||
* **When reviewing PRs, please leave a message about what was done.**
|
||||
We don't have full test coverage, so manual testing can help.
|
||||
|
||||
@@ -225,7 +225,7 @@ Sent to clients after a client requested this message be sent to them, more info
|
||||
| games | list\[str\] | Optional. Game names this message is targeting |
|
||||
| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
|
||||
| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
|
||||
| data | dict | The data in the [Bounce](#Bounce) package copied |
|
||||
| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
|
||||
|
||||
### InvalidPacket
|
||||
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
||||
@@ -425,7 +425,7 @@ the server will forward the message to all those targets to which any one requir
|
||||
| games | list\[str\] | Optional. Game names that should receive this message |
|
||||
| slots | list\[int\] | Optional. Player IDs that should receive this message |
|
||||
| tags | list\[str\] | Optional. Client tags that should receive this message |
|
||||
| data | dict | Any data you want to send |
|
||||
| data | dict | Optional. Any data you want to send |
|
||||
|
||||
### Get
|
||||
Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package.
|
||||
@@ -647,6 +647,16 @@ class Version(NamedTuple):
|
||||
build: int
|
||||
```
|
||||
|
||||
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
|
||||
```
|
||||
"version": {
|
||||
"class": "Version",
|
||||
"build": X,
|
||||
"major": Y,
|
||||
"minor": Z
|
||||
}
|
||||
```
|
||||
|
||||
### SlotType
|
||||
An enum representing the nature of a slot.
|
||||
|
||||
|
||||
@@ -269,7 +269,8 @@ placed on them.
|
||||
|
||||
### PriorityLocations
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
|
||||
the pool.
|
||||
the pool. Progression items without a deprioritized flag will be used first when filling priority_locations. Progression items with
|
||||
a deprioritized flag will be used next.
|
||||
|
||||
### ItemLinks
|
||||
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between
|
||||
|
||||
482
docs/rule builder.md
Normal file
482
docs/rule builder.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Rule Builder
|
||||
|
||||
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface to define rules and the following advantages:
|
||||
|
||||
- Rule classes that avoid all the common pitfalls
|
||||
- Logic optimization
|
||||
- Automatic result caching (opt-in)
|
||||
- Serialization/deserialization
|
||||
- Human-readable logic explanations for players
|
||||
|
||||
## Overview
|
||||
|
||||
The rule builder consists of 3 main parts:
|
||||
|
||||
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic. They can be combined and take into account your world's options. There are a number of default rules listed below, and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance they must be resolved.
|
||||
1. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly creating these but they'll be created when assigning rules to locations or entrances. These are what power the human-readable logic explanations.
|
||||
1. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
|
||||
|
||||
## Usage
|
||||
|
||||
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
|
||||
|
||||
```python
|
||||
# In your world's create_regions method
|
||||
location = MyWorldLocation(...)
|
||||
self.set_rule(location, Has("A Big Gun"))
|
||||
```
|
||||
|
||||
The rule builder comes with a number of rules by default:
|
||||
|
||||
- `True_`: Always returns true
|
||||
- `False_`: Always returns false
|
||||
- `And`: Checks that all child rules are true (also provided by `&` operator)
|
||||
- `Or`: Checks that at least one child rule is true (also provided by `|` operator)
|
||||
- `Has`: Checks that the player has the given item with the given count (default 1)
|
||||
- `HasAll`: Checks that the player has all given items
|
||||
- `HasAny`: Checks that the player has at least one of the given items
|
||||
- `HasAllCounts`: Checks that the player has all of the counts for the given items
|
||||
- `HasAnyCount`: Checks that the player has any of the counts for the given items
|
||||
- `HasFromList`: Checks that the player has some number of given items
|
||||
- `HasFromListUnique`: Checks that the player has some number of given items, ignoring duplicates of the same item
|
||||
- `HasGroup`: Checks that the player has some number of items from a given item group
|
||||
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the same item
|
||||
- `CanReachLocation`: Checks that the player can logically reach the given location
|
||||
- `CanReachRegion`: Checks that the player can logically reach the given region
|
||||
- `CanReachEntrance`: Checks that the player can logically reach the given entrance
|
||||
|
||||
You can combine these rules together to describe the logic required for something. For example, to check if a player either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
|
||||
|
||||
```python
|
||||
rule = Has("Movement ability") | HasAll("Key 1", "Key 2")
|
||||
```
|
||||
|
||||
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check if a rule is defined you must use `if rule is not None`.
|
||||
|
||||
### Assigning rules
|
||||
|
||||
When assigning the rule you must use the `set_rule` helper to correctly resolve and register the rule.
|
||||
|
||||
```python
|
||||
self.set_rule(location_or_entrance, rule)
|
||||
```
|
||||
|
||||
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify `force_creation=True` if you would like to create the entrance even if the rule is `False`.
|
||||
|
||||
```python
|
||||
self.create_entrance(from_region, to_region, rule)
|
||||
```
|
||||
|
||||
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
|
||||
|
||||
You can also set a rule for your world's completion condition:
|
||||
|
||||
```python
|
||||
self.set_completion_rule(rule)
|
||||
```
|
||||
|
||||
### Restricting options
|
||||
|
||||
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an iterable of `OptionFilter` instances. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
|
||||
|
||||
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
|
||||
|
||||
- `eq`: `==`
|
||||
- `ne`: `!=`
|
||||
- `gt`: `>`
|
||||
- `lt`: `<`
|
||||
- `ge`: `>=`
|
||||
- `le`: `<=`
|
||||
- `contains`: `in`
|
||||
|
||||
By default rules that are excluded by their options will default to `False`. If you want to default to `True` instead, you can specify `filtered_resolution=True` on your rule.
|
||||
|
||||
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
|
||||
|
||||
```python
|
||||
rule = (
|
||||
Has("Red switch", options=[OptionFilter(SwitchRando, 1)])
|
||||
| CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)])
|
||||
)
|
||||
```
|
||||
|
||||
To add an extra logic requirement on the easiest difficulty which is ignored for other difficulties:
|
||||
|
||||
```python
|
||||
rule = (
|
||||
# ...the rest of the logic
|
||||
& Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)], filtered_resolution=True)
|
||||
)
|
||||
```
|
||||
|
||||
If you would like to provide option filters when reusing or composing rules, you can use the `Filtered` helper rule:
|
||||
|
||||
```python
|
||||
common_rule = Has("A") | HasAny("B", "C")
|
||||
...
|
||||
rule = (
|
||||
Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
|
||||
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
|
||||
)
|
||||
```
|
||||
|
||||
You can also use the & and | operators to apply options to rules:
|
||||
|
||||
```python
|
||||
common_rule = Has("A")
|
||||
easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)]
|
||||
common_rule_only_on_easy = common_rule & easy_filter
|
||||
common_rule_skipped_on_easy = common_rule | easy_filter
|
||||
```
|
||||
|
||||
## Enabling caching
|
||||
|
||||
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
|
||||
|
||||
```python
|
||||
class MyWorld(CachedRuleBuilderWorld):
|
||||
game = "My Game"
|
||||
```
|
||||
|
||||
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
|
||||
|
||||
### Item name mapping
|
||||
|
||||
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps actual item names to real item names so the cache system knows what to invalidate.
|
||||
|
||||
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`.
|
||||
|
||||
```python
|
||||
class MyWorld(CachedRuleBuilderWorld):
|
||||
item_mapping = {
|
||||
"Currency x10": "Currency",
|
||||
"Currency x50": "Currency",
|
||||
"Currency x100": "Currency",
|
||||
"Currency x500": "Currency",
|
||||
}
|
||||
```
|
||||
|
||||
## Defining custom rules
|
||||
|
||||
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
|
||||
|
||||
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more dependencies functions as outlined below.
|
||||
|
||||
To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement:
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class CanGoal(Rule["MyWorld"], game="My Game"):
|
||||
@override
|
||||
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
|
||||
# caching_enabled only needs to be passed in when your world inherits from CachedRuleBuilderWorld
|
||||
return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=True)
|
||||
|
||||
class Resolved(Rule.Resolved):
|
||||
goal: int
|
||||
|
||||
@override
|
||||
def _evaluate(self, state: CollectionState) -> bool:
|
||||
return state.has("McGuffin", self.player, count=self.goal)
|
||||
|
||||
@override
|
||||
def item_dependencies(self) -> dict[str, set[int]]:
|
||||
# this function is only required if you have caching enabled
|
||||
return {"McGuffin": {id(self)}}
|
||||
|
||||
@override
|
||||
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
|
||||
# this method can be overridden to display custom explanations
|
||||
return [
|
||||
{"type": "text", "text": "Goal with "},
|
||||
{"type": "color", "color": "green" if state and self(state) else "salmon", "text": str(self.goal)},
|
||||
{"type": "text", "text": " McGuffins"},
|
||||
]
|
||||
```
|
||||
|
||||
Your custom rule can also resolve to builtin rules instead of needing to define your own:
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
|
||||
def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
|
||||
if world.some_precalculated_bool:
|
||||
return Has("Item 1").resolve(world)
|
||||
if world.options.some_option:
|
||||
return CanReachRegion("Region 1").resolve(world)
|
||||
return False_().resolve(world)
|
||||
```
|
||||
|
||||
### Item dependencies
|
||||
|
||||
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this function even when caching is disabled as more things may use it in the future.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class MyRule(Rule["MyWorld"], game="My Game"):
|
||||
class Resolved(Rule.Resolved):
|
||||
item_name: str
|
||||
|
||||
@override
|
||||
def item_dependencies(self) -> dict[str, set[int]]:
|
||||
return {self.item_name: {id(self)}}
|
||||
```
|
||||
|
||||
All of the default `Has*` rules define this function already.
|
||||
|
||||
### Region dependencies
|
||||
|
||||
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class MyRule(Rule["MyWorld"], game="My Game"):
|
||||
class Resolved(Rule.Resolved):
|
||||
region_name: str
|
||||
|
||||
@override
|
||||
def region_dependencies(self) -> dict[str, set[int]]:
|
||||
return {self.region_name: {id(self)}}
|
||||
```
|
||||
|
||||
The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules define this function already.
|
||||
|
||||
### Location dependencies
|
||||
|
||||
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class MyRule(Rule["MyWorld"], game="My Game"):
|
||||
class Resolved(Rule.Resolved):
|
||||
location_name: str
|
||||
|
||||
@override
|
||||
def location_dependencies(self) -> dict[str, set[int]]:
|
||||
return {self.location_name: {id(self)}}
|
||||
```
|
||||
|
||||
The default `CanReachLocation` rule defines this function already.
|
||||
|
||||
### Entrance dependencies
|
||||
|
||||
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
class MyRule(Rule["MyWorld"], game="My Game"):
|
||||
class Resolved(Rule.Resolved):
|
||||
entrance_name: str
|
||||
|
||||
@override
|
||||
def entrance_dependencies(self) -> dict[str, set[int]]:
|
||||
return {self.entrance_name: {id(self)}}
|
||||
```
|
||||
|
||||
The default `CanReachEntrance` rule defines this function already.
|
||||
|
||||
### Rule explanations
|
||||
|
||||
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
|
||||
|
||||
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class:
|
||||
|
||||
```python
|
||||
class MyRule(Rule, game="My Game"):
|
||||
class Resolved(Rule.Resolved):
|
||||
@override
|
||||
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
|
||||
has_item = state and state.has("growth spurt", self.player)
|
||||
color = "yellow"
|
||||
start = "You must be "
|
||||
if has_item:
|
||||
start = "You are "
|
||||
color = "green"
|
||||
elif state is not None:
|
||||
start = "You are not "
|
||||
color = "salmon"
|
||||
return [
|
||||
{"type": "text", "text": start},
|
||||
{"type": "color", "color": color, "text": "THIS"},
|
||||
{"type": "text", "text": " tall to beat the game"},
|
||||
]
|
||||
|
||||
@override
|
||||
def explain_str(self, state: CollectionState | None = None) -> str:
|
||||
if state is None:
|
||||
return str(self)
|
||||
if state.has("growth spurt", self.player):
|
||||
return "You ARE this tall and can beat the game"
|
||||
return "You are not THIS tall and cannot beat the game"
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return "You must be THIS tall to beat the game"
|
||||
```
|
||||
|
||||
### Cache control
|
||||
|
||||
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two class attributes on the `Resolved` class you can override to change this behavior.
|
||||
|
||||
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your rule should be marked as stale.
|
||||
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the overhead of the caching system will slow it down.
|
||||
|
||||
### Caveats
|
||||
|
||||
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if your world has opted into caching.
|
||||
- Resolved rules are forced to be frozen dataclasses. They and all their attributes must be immutable and hashable.
|
||||
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly.
|
||||
|
||||
## Serialization
|
||||
|
||||
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
|
||||
|
||||
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule would look like:
|
||||
|
||||
```python
|
||||
{
|
||||
"rule": "Has",
|
||||
"options": [],
|
||||
"args": {
|
||||
"item_name": "Some item",
|
||||
"count": 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in the same serializable format:
|
||||
|
||||
```python
|
||||
{
|
||||
"rule": "And",
|
||||
"options": [],
|
||||
"children": [
|
||||
..., # each serialized rule
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A full example is as follows:
|
||||
|
||||
```python
|
||||
rule = And(
|
||||
Has("a", options=[OptionFilter(ToggleOption, 0)]),
|
||||
Or(Has("b", count=2), CanReachRegion("c"), options=[OptionFilter(ToggleOption, 1)]),
|
||||
)
|
||||
assert rule.to_dict() == {
|
||||
"rule": "And",
|
||||
"options": [],
|
||||
"children": [
|
||||
{
|
||||
"rule": "Has",
|
||||
"options": [
|
||||
{
|
||||
"option": "worlds.my_world.options.ToggleOption",
|
||||
"value": 0,
|
||||
"operator": "eq",
|
||||
},
|
||||
],
|
||||
"args": {
|
||||
"item_name": "a",
|
||||
"count": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"rule": "Or",
|
||||
"options": [
|
||||
{
|
||||
"option": "worlds.my_world.options.ToggleOption",
|
||||
"value": 1,
|
||||
"operator": "eq",
|
||||
},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"rule": "Has",
|
||||
"options": [],
|
||||
"args": {
|
||||
"item_name": "b",
|
||||
"count": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
"rule": "CanReachRegion",
|
||||
"options": [],
|
||||
"args": {
|
||||
"region_name": "c",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Custom serialization
|
||||
|
||||
To define a different format for your custom rules, override the `to_dict` function:
|
||||
|
||||
```python
|
||||
class BasicLogicRule(Rule, game="My Game"):
|
||||
items = ("one", "two")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
# Return whatever format works best for you
|
||||
return {
|
||||
"logic": "basic",
|
||||
"items": self.items,
|
||||
}
|
||||
```
|
||||
|
||||
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it correctly:
|
||||
|
||||
```python
|
||||
class BasicLogicRule(Rule, game="My Game"):
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self:
|
||||
items = data.get("items", ())
|
||||
return cls(*items)
|
||||
```
|
||||
|
||||
## APIs
|
||||
|
||||
This section is provided for reference, refer to the above sections for examples.
|
||||
|
||||
### World API
|
||||
|
||||
These are properties and helpers that are available to you in your world.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation
|
||||
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies, gets called automatically after set_rules
|
||||
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance
|
||||
- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world
|
||||
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` unless force_creation is `True`
|
||||
|
||||
#### CachedRuleBuilderWorld Properties
|
||||
|
||||
The following property is only available when inheriting from `CachedRuleBuilderWorld`
|
||||
|
||||
- `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name
|
||||
|
||||
### Rule API
|
||||
|
||||
These are properties and helpers that you can use or override for custom rules.
|
||||
|
||||
- `_instantiate(world: World)`: Create a new resolved rule instance, override for custom rules as required
|
||||
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's serialization
|
||||
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if you've overridden `to_dict`
|
||||
- `__str__()`: Basic string representation of a rule, useful for debugging
|
||||
|
||||
#### Resolved rule API
|
||||
|
||||
- `player: int`: The slot this rule is resolved for
|
||||
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for this rule
|
||||
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item collection
|
||||
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching regions
|
||||
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on reaching locations
|
||||
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on reaching entrances
|
||||
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules
|
||||
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
|
||||
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules
|
||||
@@ -7,10 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
|
||||
## General
|
||||
|
||||
What you'll need:
|
||||
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||
* [Python 3.11.9 or newer but less than 3.14](https://www.python.org/downloads/), not the Windows Store version
|
||||
* On Windows, please consider only using the latest supported version in production environments since security
|
||||
updates for older versions are not easily available.
|
||||
* Python 3.13.x is currently the newest supported version
|
||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||
* Matching C compiler
|
||||
* possibly optional, read operating system specific sections
|
||||
@@ -53,6 +52,32 @@ Recommended steps
|
||||
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
|
||||
|
||||
|
||||
## Linux
|
||||
|
||||
If your Linux distribution ships a compatible Python version (see [General](#general)) and pip, you can use that,
|
||||
otherwise you may need to install Python from a 3rd party. Refer to documentation of your Linux distribution.
|
||||
|
||||
Installing a C compiler is usually optional. The package is typically named `gcc`, sometimes another package with the
|
||||
base build tools may be required, i.e. `build-essential` (Debian/Ubuntu) or `base-devel` (Arch).
|
||||
|
||||
After getting the source code, it is strongly recommended to create a
|
||||
[venv](https://docs.python.org/3/tutorial/venv.html) (Virtual Environment)
|
||||
by hand or using an IDE, such as PyCharm, because Archipelago requires specific versions of Python packages.
|
||||
|
||||
Run `python ModuleUpdate.py` in the project root to install packages, run `python Launcher.py` to run the Launcher.
|
||||
|
||||
### Building
|
||||
|
||||
Builds contain (almost) all dependencies to run Archipelago on any Linux distribution that is as new or newer than the
|
||||
one it was built on. Beware that currently only the oldest Ubuntu LTS available in GitHub actions is supported for that.
|
||||
This means the easiest way to generate a build is by running the `Build` action from GitHub actions instead of building
|
||||
locally. If you still want to, e.g. for local testing, you can by running
|
||||
|
||||
`python setup.py build_exe` to generate a binary distribution of Archipelago in `build/`. Or to generate an AppImage
|
||||
first generate the binary distribution and then run `python setup.py bdist_appimage` to populate `dist/`. You need to
|
||||
put an `appimagetool` into the directory you run the command from, rename it to `appimagetool` and make it executable.
|
||||
|
||||
|
||||
## Optional: A Link to the Past Enemizer
|
||||
|
||||
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||
|
||||
@@ -47,21 +47,27 @@
|
||||
|
||||
## HTML
|
||||
|
||||
* Indent with 2 spaces for new code.
|
||||
* Indent with 4 spaces for new code.
|
||||
* kebab-case for ids and classes.
|
||||
* Avoid using on* attributes (onclick, etc.).
|
||||
|
||||
## CSS
|
||||
## CSS / SCSS
|
||||
|
||||
* Indent with 2 spaces for new code.
|
||||
* Indent with 4 spaces for new code.
|
||||
* `{` on the same line as the selector.
|
||||
* No space between selector and `{`.
|
||||
* Space between selector and `{`.
|
||||
|
||||
## JS
|
||||
|
||||
* Indent with 2 spaces.
|
||||
* Indent `case` inside `switch ` with 2 spaces.
|
||||
* Use single quotes.
|
||||
* Indent with 4 spaces.
|
||||
* Indent `case` inside `switch ` with 4 spaces.
|
||||
* Prefer double quotation marks (`"`).
|
||||
* Semicolons are required after every statement.
|
||||
* Use [IIFEs](https://developer.mozilla.org/docs/Glossary/IIFE) to avoid polluting global scope.
|
||||
* Prefer to use [defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#defer)
|
||||
in script tags, which retains order of execution but does not block.
|
||||
* Avoid `<script async ...` in most cases, see [async and defer](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#async_and_defer).
|
||||
* Use addEventListener.
|
||||
|
||||
## KV
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Archipelago has a rudimentary API that can be queried by endpoints. The API is a
|
||||
|
||||
The following API requests are formatted as: `https://<Archipelago URL>/api/<endpoint>`
|
||||
|
||||
The returned data will be formated in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
|
||||
The returned data will be formatted in a combination of JSON lists or dicts, with their keys or values being notated in `blocks` (if applicable)
|
||||
|
||||
Current endpoints:
|
||||
- Datapackage API
|
||||
@@ -24,13 +24,21 @@ Current endpoints:
|
||||
- [`/get_rooms`](#getrooms)
|
||||
- [`/get_seeds`](#getseeds)
|
||||
|
||||
## API Data Caching
|
||||
To reduce the strain on an Archipelago WebHost, many API endpoints will cache their data and only poll new data in timed intervals. Each endpoint has their own caching time related to the type of data being served. More dynamic data is refreshed more frequently, while static data is cached for longer.
|
||||
Each API endpoint will have their "Cache timer" listed under their definition (if any).
|
||||
API calls to these endpoints should not be faster than the listed timer. This will result in wasted processing for your client and (more importantly) the Archipelago WebHost, as the data will not be refreshed by the WebHost until the internal timer has elapsed.
|
||||
|
||||
|
||||
## Datapackage Endpoints
|
||||
These endpoints are used by applications to acquire a room's datapackage, and validate that they have the correct datapackage for use. Datapackages normally include, item IDs, location IDs, and name groupings, for a given room, and are essential for mapping IDs received from Archipelago to their correct items or locations.
|
||||
|
||||
### `/datapackage`
|
||||
<a name="datapackage"></a>
|
||||
|
||||
Fetches the current datapackage from the WebHost.
|
||||
**Cache timer: None**
|
||||
|
||||
You'll receive a dict named `games` that contains a named dict of every game and its data currently supported by Archipelago.
|
||||
Each game will have:
|
||||
- A checksum `checksum`
|
||||
@@ -40,7 +48,7 @@ Each game will have:
|
||||
- Location name to AP ID dict `location_name_to_id`
|
||||
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"games": {
|
||||
...
|
||||
@@ -76,7 +84,10 @@ Example:
|
||||
|
||||
### `/datapackage/<string:checksum>`
|
||||
<a name="datapackagestringchecksum"></a>
|
||||
Fetches a single datapackage by checksum.
|
||||
|
||||
Fetches a single datapackage by checksum.
|
||||
**Cache timer: None**
|
||||
|
||||
Returns a dict of the game's data with:
|
||||
- A checksum `checksum`
|
||||
- A dict of item groups `item_name_groups`
|
||||
@@ -88,10 +99,13 @@ Its format will be identical to the whole-datapackage endpoint (`/datapackage`),
|
||||
|
||||
### `/datapackage_checksum`
|
||||
<a name="datapackagechecksum"></a>
|
||||
Fetches the checksums of the current static datapackages on the WebHost.
|
||||
|
||||
Fetches the checksums of the current static datapackages on the WebHost.
|
||||
**Cache timer: None**
|
||||
|
||||
You'll receive a dict with `game:checksum` key-value pairs for all the current officially supported games.
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
{
|
||||
...
|
||||
"Donkey Kong Country 3":"f90acedcd958213f483a6a4c238e2a3faf92165e",
|
||||
@@ -108,6 +122,7 @@ These endpoints are used internally for the WebHost to generate games and valida
|
||||
<a name="generate"></a>
|
||||
Submits a game to the WebHost for generation.
|
||||
**This endpoint only accepts a POST HTTP request.**
|
||||
**Cache timer: None**
|
||||
|
||||
There are two ways to submit data for generation: With a file and with JSON.
|
||||
|
||||
@@ -116,7 +131,7 @@ Have your ZIP of yaml(s) or a single yaml, and submit a POST request to the `/ge
|
||||
If the options are valid, you'll be returned a successful generation response. (see [Generation Response](#generation-response))
|
||||
|
||||
Example using the python requests library:
|
||||
```
|
||||
```python
|
||||
file = {'file': open('Games.zip', 'rb')}
|
||||
req = requests.post("https://archipelago.gg/api/generate", files=file)
|
||||
```
|
||||
@@ -127,7 +142,7 @@ Finally, submit a POST request to the `/generate` endpoint.
|
||||
If the weighted options are valid, you'll be returned a successful generation response (see [Generation Response](#generation-response))
|
||||
|
||||
Example using the python requests library:
|
||||
```
|
||||
```python
|
||||
data = {"Test":{"game": "Factorio","name": "Test","Factorio": {}},}
|
||||
weights={"weights": data}
|
||||
req = requests.post("https://archipelago.gg/api/generate", json=weights)
|
||||
@@ -143,7 +158,7 @@ Upon successful generation, you'll be sent a JSON dict response detailing the ge
|
||||
- The API status page of the generation `wait_api_url` (see [Status Endpoint](#status))
|
||||
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"detail": "19878f16-5a58-4b76-aab7-d6bf38be9463",
|
||||
"encoded": "GYePFlpYS3aqt9a_OL6UYw",
|
||||
@@ -167,12 +182,14 @@ If the generation detects a issue in generation, you'll be sent a dict with two
|
||||
- Detailed issue in `detail`
|
||||
|
||||
In the event of an unhandled server exception, you'll be provided a dict with a single key `text`:
|
||||
- Exception, `Uncought Exception: <error>` with a 500 status code
|
||||
- Exception, `Uncaught Exception: <error>` with a 500 status code
|
||||
|
||||
### `/status/<suuid:seed>`
|
||||
<a name="status"></a>
|
||||
Retrieves the status of the seed's generation.
|
||||
This endpoint will return a dict with a single key-vlaue pair. The key will always be `text`
|
||||
**Cache timer: None**
|
||||
|
||||
This endpoint will return a dict with a single key-value pair. The key will always be `text`
|
||||
The value will tell you the status of the generation:
|
||||
- Generation was completed: `Generation done` with a 201 status code
|
||||
- Generation request was not found: `Generation not found` with a 404 status code
|
||||
@@ -184,6 +201,8 @@ Endpoints to fetch information of the active WebHost room with the supplied room
|
||||
|
||||
### `/room_status/<suuid:room_id>`
|
||||
<a name="roomstatus"></a>
|
||||
**Cache timer: None**
|
||||
|
||||
Will provide a dict of room data with the following keys:
|
||||
- Tracker SUUID (`tracker`)
|
||||
- A list of players (`players`)
|
||||
@@ -192,10 +211,10 @@ Will provide a dict of room data with the following keys:
|
||||
- Last activity timestamp (`last_activity`)
|
||||
- The room timeout counter (`timeout`)
|
||||
- A list of downloads for files required for gameplay (`downloads`)
|
||||
- Each item is a dict containings the download URL and slot (`slot`, `download`)
|
||||
- Each item is a dict containing the download URL and slot (`slot`, `download`)
|
||||
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
{
|
||||
"downloads": [
|
||||
{
|
||||
@@ -244,7 +263,7 @@ Example:
|
||||
]
|
||||
],
|
||||
"timeout": 7200,
|
||||
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
|
||||
"tracker": "2gVkMQgISGScA8wsvDZg5A"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -254,17 +273,27 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
|
||||
|
||||
### `/tracker/<suuid:tracker>`
|
||||
<a name=tracker></a>
|
||||
**Cache timer: 60 seconds**
|
||||
|
||||
Will provide a dict of tracker data with the following keys:
|
||||
|
||||
- Each player's current alias (`aliases`)
|
||||
- Will return the name if there is none
|
||||
- A list of items each player has received as a NetworkItem (`player_items_received`)
|
||||
- A list of players current alias data (`aliases`)
|
||||
- Each item containing a dict with, their alias `alias`, their player number `player`, and their team `team`
|
||||
- `alias` will return `null` if there is no alias set
|
||||
- A list of items each player has received as a [NetworkItem](network%20protocol.md#networkitem) (`player_items_received`)
|
||||
- Each item containing a dict with, a list of NetworkItems `items`, their player number `player`, their team `team`
|
||||
- A list of checks done by each player as a list of the location id's (`player_checks_done`)
|
||||
- The total number of checks done by all players (`total_checks_done`)
|
||||
- Hints that players have used or received (`hints`)
|
||||
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
|
||||
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
|
||||
- The current client status of each player (`player_status`)
|
||||
- Each item containing a dict with, a list of checked location id's `locations`, their player number `player`, and their team `team`
|
||||
- A list of the total number of checks done by all players (`total_checks_done`)
|
||||
- Each item will contain a dict with, the total checks done `checks_done`, and the team `team`
|
||||
- A list of [Hints](network%20protocol.md#hint) data that players have used or received (`hints`)
|
||||
- Each item containing a dict containing, a list of hint data `hints`, the player number `player`, and their team `team`
|
||||
- A list containing the last activity time for each player, formatted in RFC 1123 format (`activity_timers`)
|
||||
- Each item containing, last activity time `time`, their player number `player`, and their team `team`
|
||||
- A list containing the last connection time for each player, formatted in RFC 1123 format (`connection_timers`)
|
||||
- Each item containing, the time of their last connection `time`, their player number `player`, and their team `team`
|
||||
- A list of the current [ClientStatus](network%20protocol.md#clientstatus) of each player (`player_status`)
|
||||
- Each item will contain, their status `status`, their player number `player`, and their team `team`
|
||||
|
||||
Example:
|
||||
```json
|
||||
@@ -279,7 +308,12 @@ Example:
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"alias": "Slot_Name_2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 3,
|
||||
"alias": null
|
||||
},
|
||||
],
|
||||
"player_items_received": [
|
||||
{
|
||||
@@ -378,13 +412,21 @@ Example:
|
||||
|
||||
### `/static_tracker/<suuid:tracker>`
|
||||
<a name=statictracker></a>
|
||||
**Cache timer: 300 seconds**
|
||||
|
||||
Will provide a dict of static tracker data with the following keys:
|
||||
|
||||
- item_link groups and their players (`groups`)
|
||||
- The datapackage hash for each game (`datapackage`)
|
||||
- A list of item_link groups and their member players (`groups`)
|
||||
- Each item containing a dict with, the slot registering the group `slot`, the item_link name `name`, and a list of members `members`
|
||||
- A dict of datapackage hashes for each game (`datapackage`)
|
||||
- Each item is a named dict of the game's name.
|
||||
- Each game contains two keys, the datapackage's checksum hash `checksum`, and the version `version`
|
||||
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
|
||||
- The number of checks found vs. total checks available per player (`player_locations_total`)
|
||||
- A list of number of checks found vs. total checks available per player (`player_locations_total`)
|
||||
- Each list item contains a dict with three keys, the total locations for that slot `total_locations`, their player number `player`, and their team `team`
|
||||
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
|
||||
- The game each player is playing (`player_game`)
|
||||
- Provided as a list of objects with `team`, `player`, and `game`.
|
||||
|
||||
Example:
|
||||
```json
|
||||
@@ -409,10 +451,10 @@ Example:
|
||||
],
|
||||
"datapackage": {
|
||||
"Archipelago": {
|
||||
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
|
||||
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb"
|
||||
},
|
||||
"The Messenger": {
|
||||
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
|
||||
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b"
|
||||
}
|
||||
},
|
||||
"player_locations_total": [
|
||||
@@ -427,12 +469,29 @@ Example:
|
||||
"total_locations": 20
|
||||
}
|
||||
],
|
||||
"player_game": [
|
||||
{
|
||||
"team": 0,
|
||||
"player": 1,
|
||||
"game": "Archipelago"
|
||||
},
|
||||
{
|
||||
"team": 0,
|
||||
"player": 2,
|
||||
"game": "The Messenger"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `/slot_data_tracker/<suuid:tracker>`
|
||||
<a name=slotdatatracker></a>
|
||||
Will provide a list of each player's slot_data.
|
||||
Will provide a list of each player's slot_data.
|
||||
**Cache timer: 300 seconds**
|
||||
|
||||
Each list item will contain a dict with the player's data:
|
||||
- player slot number `player`
|
||||
- A named dict `slot_data` containing any set slot data for that player
|
||||
|
||||
Example:
|
||||
```json
|
||||
@@ -460,6 +519,8 @@ User endpoints can get room and seed details from the current session tokens (co
|
||||
### `/get_rooms`
|
||||
<a name="getrooms"></a>
|
||||
Retreives a list of all rooms currently owned by the session token.
|
||||
**Cache timer: None**
|
||||
|
||||
Each list item will contain a dict with the room's details:
|
||||
- Room SUUID (`room_id`)
|
||||
- Seed SUUID (`seed_id`)
|
||||
@@ -470,25 +531,25 @@ Each list item will contain a dict with the room's details:
|
||||
- Room tracker SUUID (`tracker`)
|
||||
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
[
|
||||
{
|
||||
"creation_time": "Fri, 18 Apr 2025 19:46:53 GMT",
|
||||
"last_activity": "Fri, 18 Apr 2025 21:16:02 GMT",
|
||||
"last_port": 52122,
|
||||
"room_id": "90ae5f9b-177c-4df8-ac53-9629fc3bff7a",
|
||||
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6",
|
||||
"room_id": "0D30FgQaRcWivFsw9o8qzw",
|
||||
"seed_id": "TFjiarBgTsCj5-Jbe8u33A",
|
||||
"timeout": 7200,
|
||||
"tracker": "cf6989c0-4703-45d7-a317-2e5158431171"
|
||||
"tracker": "52BycvJhRe6knrYH8v4bag"
|
||||
},
|
||||
{
|
||||
"creation_time": "Fri, 18 Apr 2025 20:36:42 GMT",
|
||||
"last_activity": "Fri, 18 Apr 2025 20:36:46 GMT",
|
||||
"last_port": 56884,
|
||||
"room_id": "14465c05-d08e-4d28-96bd-916f994609d8",
|
||||
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb",
|
||||
"room_id": "LMCFchESSNyuqcY3GxkhwA",
|
||||
"seed_id": "CENtJMXCTGmkIYCzjB5Csg",
|
||||
"timeout": 7200,
|
||||
"tracker": "4e624bd8-32b6-42e4-9178-aa407f72751c"
|
||||
"tracker": "2gVkMQgISGScA8wsvDZg5A"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -496,6 +557,8 @@ Example:
|
||||
### `/get_seeds`
|
||||
<a name="getseeds"></a>
|
||||
Retreives a list of all seeds currently owned by the session token.
|
||||
**Cache timer: None**
|
||||
|
||||
Each item in the list will contain a dict with the seed's details:
|
||||
- Seed SUUID (`seed_id`)
|
||||
- Creation timestamp (`creation_time`)
|
||||
@@ -503,7 +566,7 @@ Each item in the list will contain a dict with the seed's details:
|
||||
- Each item in the list will contain a list of the slot name and game
|
||||
|
||||
Example:
|
||||
```
|
||||
```json
|
||||
[
|
||||
{
|
||||
"creation_time": "Fri, 18 Apr 2025 19:46:52 GMT",
|
||||
@@ -529,7 +592,7 @@ Example:
|
||||
"Ocarina of Time"
|
||||
]
|
||||
],
|
||||
"seed_id": "efbd62c2-aaeb-4dda-88c3-f461c029cef6"
|
||||
"seed_id": "CENtJMXCTGmkIYCzjB5Csg"
|
||||
},
|
||||
{
|
||||
"creation_time": "Fri, 18 Apr 2025 20:36:39 GMT",
|
||||
@@ -551,7 +614,7 @@ Example:
|
||||
"Archipelago"
|
||||
]
|
||||
],
|
||||
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
||||
"seed_id": "TFjiarBgTsCj5-Jbe8u33A"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -225,7 +225,10 @@ and has a classification. The name needs to be unique within each game and must
|
||||
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||
Locations and items can share IDs, and locations can share IDs with other games' locations.
|
||||
|
||||
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
||||
World-specific IDs **must** be in the range 1 to 2<sup>53</sup>-1 (the largest integer that is "[safe](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER#description)"
|
||||
to store in a 64-bit float, and thus all popular programming languages can handle). IDs ≤ 0 are global and reserved.
|
||||
It's **recommended** to keep your IDs in the range 1 to 2<sup>31</sup>-1,
|
||||
so only 32-bit integers are needed to hold your IDs.
|
||||
|
||||
Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED`.
|
||||
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
|
||||
|
||||
@@ -525,7 +525,7 @@ def randomize_entrances(
|
||||
|
||||
running_time = time.perf_counter() - start_time
|
||||
if running_time > 1.0:
|
||||
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
|
||||
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}, "
|
||||
f"named {world.multiworld.player_name[world.player]}")
|
||||
|
||||
return er_state
|
||||
|
||||
@@ -208,6 +208,11 @@ Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "
|
||||
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apeb"; ValueData: "{#MyAppName}ebpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archipelago EarthBound Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
23
kvui.py
23
kvui.py
@@ -19,6 +19,7 @@ os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
os.environ["KIVY_NO_ARGS"] = "1"
|
||||
os.environ["KIVY_LOG_ENABLE"] = "0"
|
||||
os.environ["SDL_MOUSE_FOCUS_CLICKTHROUGH"] = "1"
|
||||
|
||||
import Utils
|
||||
|
||||
@@ -35,6 +36,17 @@ Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set("kivy", "exit_on_escape", "0")
|
||||
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
||||
|
||||
# Workaround for Kivy issue #9226.
|
||||
# caused by kivy by default using probesysfs,
|
||||
# which assumes all multi touch deviecs are touch screens.
|
||||
# workaround provided by Snu of the kivy commmunity c:
|
||||
from kivy.utils import platform
|
||||
if platform == "linux":
|
||||
options = Config.options("input")
|
||||
for option in options:
|
||||
if Config.get("input", option) == "probesysfs":
|
||||
Config.remove_option("input", option)
|
||||
|
||||
# Workaround for an issue where importing kivy.core.window before loading sounds
|
||||
# will hang the whole application on Linux once the first sound is loaded.
|
||||
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
|
||||
@@ -127,7 +139,7 @@ class ImageButton(MDIconButton):
|
||||
val = kwargs.pop(kwarg, "None")
|
||||
if val != "None":
|
||||
image_args[kwarg.replace("image_", "")] = val
|
||||
super().__init__()
|
||||
super().__init__(**kwargs)
|
||||
self.image = ApAsyncImage(**image_args)
|
||||
|
||||
def set_center(button, center):
|
||||
@@ -143,6 +155,7 @@ class ImageButton(MDIconButton):
|
||||
|
||||
class ScrollBox(MDScrollView):
|
||||
layout: MDBoxLayout = ObjectProperty(None)
|
||||
box_height: int = NumericProperty(dp(100))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -153,6 +166,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ToggleButton, self).__init__(*args, **kwargs)
|
||||
self.bind(state=self._update_bg)
|
||||
self._update_bg(self, self.state)
|
||||
|
||||
def _update_bg(self, _, state: str):
|
||||
if self.disabled:
|
||||
@@ -170,7 +184,7 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
|
||||
child.text_color = self.theme_cls.onPrimaryColor
|
||||
child.icon_color = self.theme_cls.onPrimaryColor
|
||||
else:
|
||||
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
|
||||
self.md_bg_color = self.theme_cls.surfaceContainerLowColor
|
||||
for child in self.children:
|
||||
if child.theme_text_color == "Primary":
|
||||
child.theme_text_color = "Custom"
|
||||
@@ -184,7 +198,6 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
|
||||
class ResizableTextField(MDTextField):
|
||||
"""
|
||||
Resizable MDTextField that manually overrides the builtin sizing.
|
||||
|
||||
Note that in order to use this, the sizing must be specified from within a .kv rule.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -248,7 +261,7 @@ Factory.register("HoverBehavior", HoverBehavior)
|
||||
|
||||
|
||||
class ToolTip(MDTooltipPlain):
|
||||
pass
|
||||
markup = True
|
||||
|
||||
|
||||
class ServerToolTip(ToolTip):
|
||||
@@ -283,6 +296,8 @@ class TooltipLabel(HovererableLabel, MDTooltip):
|
||||
def on_mouse_pos(self, window, pos):
|
||||
if not self.get_root_window():
|
||||
return # Abort if not displayed
|
||||
if self.disabled:
|
||||
return
|
||||
super().on_mouse_pos(window, pos)
|
||||
if self.refs and self.hovered:
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
colorama>=0.4.6
|
||||
websockets>=13.0.1,<14
|
||||
PyYAML>=6.0.2
|
||||
jellyfish>=1.1.3
|
||||
PyYAML>=6.0.3
|
||||
jellyfish>=1.2.1
|
||||
jinja2>=3.1.6
|
||||
schema>=0.7.7
|
||||
schema>=0.7.8
|
||||
kivy>=2.3.1
|
||||
bsdiff4>=1.2.6
|
||||
platformdirs>=4.3.6
|
||||
certifi>=2025.4.26
|
||||
cython>=3.0.12
|
||||
cymem>=2.0.11
|
||||
orjson>=3.10.15
|
||||
typing_extensions>=4.12.2
|
||||
pyshortcuts>=1.9.1
|
||||
platformdirs>=4.5.0
|
||||
certifi>=2025.11.12
|
||||
cython>=3.2.1
|
||||
cymem>=2.0.13
|
||||
orjson>=3.11.4
|
||||
typing_extensions>=4.15.0
|
||||
pyshortcuts>=1.9.6
|
||||
pathspec>=0.12.1
|
||||
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
|
||||
|
||||
0
rule_builder/__init__.py
Normal file
0
rule_builder/__init__.py
Normal file
146
rule_builder/cached_world.py
Normal file
146
rule_builder/cached_world.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from collections import defaultdict
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from BaseClasses import CollectionState, Item, MultiWorld, Region
|
||||
from worlds.AutoWorld import LogicMixin, World
|
||||
|
||||
from .rules import Rule
|
||||
|
||||
|
||||
class CachedRuleBuilderWorld(World):
|
||||
"""A World subclass that provides helpers for interacting with the rule builder"""
|
||||
|
||||
rule_item_dependencies: dict[str, set[int]]
|
||||
"""A mapping of item name to set of rule ids"""
|
||||
|
||||
rule_region_dependencies: dict[str, set[int]]
|
||||
"""A mapping of region name to set of rule ids"""
|
||||
|
||||
rule_location_dependencies: dict[str, set[int]]
|
||||
"""A mapping of location name to set of rule ids"""
|
||||
|
||||
rule_entrance_dependencies: dict[str, set[int]]
|
||||
"""A mapping of entrance name to set of rule ids"""
|
||||
|
||||
item_mapping: ClassVar[dict[str, str]] = {}
|
||||
"""A mapping of actual item name to logical item name.
|
||||
Useful when there are multiple versions of a collected item but the logic only uses one. For example:
|
||||
item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}"""
|
||||
|
||||
rule_caching_enabled: ClassVar[bool] = True
|
||||
"""Flag to inform rules that the caching system for this world is enabled. It should not be overridden."""
|
||||
|
||||
def __init__(self, multiworld: MultiWorld, player: int) -> None:
|
||||
super().__init__(multiworld, player)
|
||||
self.rule_item_dependencies = defaultdict(set)
|
||||
self.rule_region_dependencies = defaultdict(set)
|
||||
self.rule_location_dependencies = defaultdict(set)
|
||||
self.rule_entrance_dependencies = defaultdict(set)
|
||||
|
||||
@override
|
||||
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
|
||||
for item_name, rule_ids in resolved_rule.item_dependencies().items():
|
||||
self.rule_item_dependencies[item_name] |= rule_ids
|
||||
for region_name, rule_ids in resolved_rule.region_dependencies().items():
|
||||
self.rule_region_dependencies[region_name] |= rule_ids
|
||||
for location_name, rule_ids in resolved_rule.location_dependencies().items():
|
||||
self.rule_location_dependencies[location_name] |= rule_ids
|
||||
for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items():
|
||||
self.rule_entrance_dependencies[entrance_name] |= rule_ids
|
||||
|
||||
def register_rule_builder_dependencies(self) -> None:
|
||||
"""Register all rules that depend on locations or entrances with their dependencies"""
|
||||
for location_name, rule_ids in self.rule_location_dependencies.items():
|
||||
try:
|
||||
location = self.get_location(location_name)
|
||||
except KeyError:
|
||||
continue
|
||||
if not isinstance(location.access_rule, Rule.Resolved):
|
||||
continue
|
||||
for item_name in location.access_rule.item_dependencies():
|
||||
self.rule_item_dependencies[item_name] |= rule_ids
|
||||
for region_name in location.access_rule.region_dependencies():
|
||||
self.rule_region_dependencies[region_name] |= rule_ids
|
||||
|
||||
for entrance_name, rule_ids in self.rule_entrance_dependencies.items():
|
||||
try:
|
||||
entrance = self.get_entrance(entrance_name)
|
||||
except KeyError:
|
||||
continue
|
||||
if not isinstance(entrance.access_rule, Rule.Resolved):
|
||||
continue
|
||||
for item_name in entrance.access_rule.item_dependencies():
|
||||
self.rule_item_dependencies[item_name] |= rule_ids
|
||||
for region_name in entrance.access_rule.region_dependencies():
|
||||
self.rule_region_dependencies[region_name] |= rule_ids
|
||||
|
||||
@override
|
||||
def collect(self, state: CollectionState, item: Item) -> bool:
|
||||
changed = super().collect(state, item)
|
||||
if changed and self.rule_item_dependencies:
|
||||
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
mapped_name = self.item_mapping.get(item.name, "")
|
||||
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
|
||||
for rule_id in rule_ids:
|
||||
if player_results.get(rule_id, None) is False:
|
||||
del player_results[rule_id]
|
||||
|
||||
return changed
|
||||
|
||||
@override
|
||||
def remove(self, state: CollectionState, item: Item) -> bool:
|
||||
changed = super().remove(state, item)
|
||||
if not changed:
|
||||
return changed
|
||||
|
||||
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
if self.rule_item_dependencies:
|
||||
mapped_name = self.item_mapping.get(item.name, "")
|
||||
rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name]
|
||||
for rule_id in rule_ids:
|
||||
player_results.pop(rule_id, None)
|
||||
|
||||
# clear all region dependent caches as none can be trusted
|
||||
if self.rule_region_dependencies:
|
||||
for rule_ids in self.rule_region_dependencies.values():
|
||||
for rule_id in rule_ids:
|
||||
player_results.pop(rule_id, None)
|
||||
|
||||
# clear all location dependent caches as they may have lost region access
|
||||
if self.rule_location_dependencies:
|
||||
for rule_ids in self.rule_location_dependencies.values():
|
||||
for rule_id in rule_ids:
|
||||
player_results.pop(rule_id, None)
|
||||
|
||||
# clear all entrance dependent caches as they may have lost region access
|
||||
if self.rule_entrance_dependencies:
|
||||
for rule_ids in self.rule_entrance_dependencies.values():
|
||||
for rule_id in rule_ids:
|
||||
player_results.pop(rule_id, None)
|
||||
|
||||
return changed
|
||||
|
||||
@override
|
||||
def reached_region(self, state: CollectionState, region: Region) -> None:
|
||||
super().reached_region(state, region)
|
||||
if self.rule_region_dependencies:
|
||||
player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
for rule_id in self.rule_region_dependencies[region.name]:
|
||||
player_results.pop(rule_id, None)
|
||||
|
||||
|
||||
class CachedRuleBuilderLogicMixin(LogicMixin):
|
||||
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
def init_mixin(self, multiworld: "MultiWorld") -> None:
|
||||
players = multiworld.get_all_ids()
|
||||
self.rule_builder_cache = {player: {} for player in players}
|
||||
|
||||
def copy_mixin(self, new_state: "CachedRuleBuilderLogicMixin") -> "CachedRuleBuilderLogicMixin":
|
||||
new_state.rule_builder_cache = {
|
||||
player: player_results.copy() for player, player_results in self.rule_builder_cache.items()
|
||||
}
|
||||
return new_state
|
||||
91
rule_builder/options.py
Normal file
91
rule_builder/options.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import dataclasses
|
||||
import importlib
|
||||
import operator
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any, Final, Literal, Self, cast
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from Options import CommonOptions, Option
|
||||
|
||||
Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains", "in"]
|
||||
|
||||
OPERATORS: Final[dict[Operator, Callable[..., bool]]] = {
|
||||
"eq": operator.eq,
|
||||
"ne": operator.ne,
|
||||
"gt": operator.gt,
|
||||
"lt": operator.lt,
|
||||
"ge": operator.ge,
|
||||
"le": operator.le,
|
||||
"contains": operator.contains,
|
||||
"in": operator.contains,
|
||||
}
|
||||
OPERATOR_STRINGS: Final[dict[Operator, str]] = {
|
||||
"eq": "==",
|
||||
"ne": "!=",
|
||||
"gt": ">",
|
||||
"lt": "<",
|
||||
"ge": ">=",
|
||||
"le": "<=",
|
||||
}
|
||||
REVERSE_OPERATORS: Final[tuple[Operator, ...]] = ("in",)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class OptionFilter:
|
||||
option: type[Option[Any]]
|
||||
value: Any
|
||||
operator: Operator = "eq"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Returns a JSON compatible dict representation of this option filter"""
|
||||
return {
|
||||
"option": f"{self.option.__module__}.{self.option.__name__}",
|
||||
"value": self.value,
|
||||
"operator": self.operator,
|
||||
}
|
||||
|
||||
def check(self, options: CommonOptions) -> bool:
|
||||
"""Tests the given options dataclass to see if it passes this option filter"""
|
||||
option_name = next(
|
||||
(name for name, cls in 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 {options.__class__.__name__}")
|
||||
opt = cast(Option[Any] | None, getattr(options, option_name, None))
|
||||
if opt is None:
|
||||
raise ValueError(f"Invalid option: {option_name}")
|
||||
|
||||
fn = OPERATORS[self.operator]
|
||||
return fn(self.value, opt) if self.operator in REVERSE_OPERATORS else fn(opt, self.value)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Self:
|
||||
"""Returns a new OptionFilter instance from a dict representation"""
|
||||
if "option" not in data or "value" not in data:
|
||||
raise ValueError("Missing required value and/or 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")
|
||||
|
||||
value = data["value"]
|
||||
operator = data.get("operator", "eq")
|
||||
return cls(option=cast(type[Option[Any]], option), value=value, operator=operator)
|
||||
|
||||
@classmethod
|
||||
def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple[Self, ...]:
|
||||
"""Returns a tuple of OptionFilters instances from an iterable of dict representations"""
|
||||
return tuple(cls.from_dict(o) for o in data)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
op = OPERATOR_STRINGS.get(self.operator, self.operator)
|
||||
return f"{self.option.__name__} {op} {self.value}"
|
||||
1822
rule_builder/rules.py
Normal file
1822
rule_builder/rules.py
Normal file
File diff suppressed because it is too large
Load Diff
4
setup.py
4
setup.py
@@ -394,11 +394,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
assert "game" in manifest, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it "
|
||||
"does not define a \"game\"."
|
||||
)
|
||||
assert manifest["game"] == worldtype.game, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
|
||||
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import unittest
|
||||
from typing import Callable, Dict, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld, Region
|
||||
from BaseClasses import CollectionRule, MultiWorld, Region
|
||||
from rule_builder.rules import Has, Rule
|
||||
from test.general import TestWorld
|
||||
|
||||
|
||||
class TestHelpers(unittest.TestCase):
|
||||
@@ -16,6 +18,7 @@ class TestHelpers(unittest.TestCase):
|
||||
self.multiworld.game[self.player] = "helper_test_game"
|
||||
self.multiworld.player_name = {1: "Tester"}
|
||||
self.multiworld.set_seed()
|
||||
self.multiworld.worlds[self.player] = TestWorld(self.multiworld, self.player)
|
||||
|
||||
def test_region_helpers(self) -> None:
|
||||
"""Tests `Region.add_locations()` and `Region.add_exits()` have correct behavior"""
|
||||
@@ -46,8 +49,9 @@ class TestHelpers(unittest.TestCase):
|
||||
"TestRegion1": {"TestRegion3"}
|
||||
}
|
||||
|
||||
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
|
||||
"TestRegion1": lambda state: state.has("test_item", self.player)
|
||||
exit_rules: Dict[str, CollectionRule | Rule[Any]] = {
|
||||
"TestRegion1": lambda state: state.has("test_item", self.player),
|
||||
"TestRegion2": Has("test_item2"),
|
||||
}
|
||||
|
||||
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
|
||||
@@ -74,13 +78,17 @@ class TestHelpers(unittest.TestCase):
|
||||
self.assertTrue(f"{parent} -> {exit_reg}" in created_exit_names)
|
||||
if exit_reg in exit_rules:
|
||||
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
|
||||
self.assertEqual(exit_rules[exit_reg],
|
||||
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||
rule = exit_rules[exit_reg]
|
||||
if isinstance(rule, Rule):
|
||||
self.assertEqual(rule.resolve(self.multiworld.worlds[self.player]),
|
||||
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||
else:
|
||||
self.assertEqual(rule, self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||
|
||||
for region in reg_exit_set:
|
||||
for region, exit_set in reg_exit_set.items():
|
||||
current_region = self.multiworld.get_region(region, self.player)
|
||||
current_region.add_exits(reg_exit_set[region])
|
||||
current_region.add_exits(exit_set)
|
||||
exit_names = {_exit.name for _exit in current_region.exits}
|
||||
for reg_exit in reg_exit_set[region]:
|
||||
for reg_exit in exit_set:
|
||||
self.assertTrue(f"{region} -> {reg_exit}" in exit_names,
|
||||
f"{region} -> {reg_exit} not in {exit_names}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from collections import ChainMap
|
||||
from typing import Type
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld
|
||||
@@ -82,12 +83,13 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_items_in_datapackage(self):
|
||||
"""Test that any created items in the itempool are in the datapackage"""
|
||||
archipelago = AutoWorldRegister.world_types["Archipelago"]
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
for item in multiworld.itempool:
|
||||
self.assertIn(item.name, world_type.item_name_to_id)
|
||||
|
||||
self.assertIn(item.name, ChainMap(world_type.item_name_to_id, archipelago.item_name_to_id))
|
||||
|
||||
def test_item_links(self) -> None:
|
||||
"""
|
||||
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
@@ -44,19 +45,19 @@ class TestOptions(unittest.TestCase):
|
||||
}],
|
||||
[{
|
||||
"name": "ItemLinkGroup",
|
||||
"item_pool": ["Hammer", "Bow"],
|
||||
"item_pool": ["Hammer", "Sword"],
|
||||
"link_replacement": False,
|
||||
"replacement_item": None,
|
||||
}]
|
||||
]
|
||||
# we really need some sort of test world but generic doesn't have enough items for this
|
||||
world = AutoWorldRegister.world_types["A Link to the Past"]
|
||||
world = AutoWorldRegister.world_types["APQuest"]
|
||||
plando_options = PlandoOptions.from_option_string("bosses")
|
||||
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
|
||||
for link in item_links:
|
||||
link.verify(world, "tester", plando_options)
|
||||
self.assertIn("Hammer", link.value[0]["item_pool"])
|
||||
self.assertIn("Bow", link.value[0]["item_pool"])
|
||||
self.assertIn("Sword", link.value[0]["item_pool"])
|
||||
|
||||
# TODO test that the group created using these options has the items
|
||||
|
||||
@@ -81,6 +82,19 @@ class TestOptions(unittest.TestCase):
|
||||
restricted_dumps(option.from_any(option.default))
|
||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||
|
||||
def test_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"):
|
||||
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):
|
||||
self.assertFalse(any(random_key in option.valid_keys for random_key in ("random",
|
||||
"random-high",
|
||||
"random-low")))
|
||||
for key in option.valid_keys:
|
||||
self.assertFalse("random-range" in key)
|
||||
|
||||
def test_pickle_dumps_plando(self):
|
||||
"""Test that plando options using containers of a custom type can be pickled"""
|
||||
|
||||
@@ -37,3 +37,23 @@ class TestPlayerOptions(unittest.TestCase):
|
||||
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
|
||||
self.assertEqual(len(new_weights["set_1"]), 2)
|
||||
self.assertIn("option_d", new_weights["set_1"])
|
||||
|
||||
def test_update_dict_supports_negatives_and_zeroes(self):
|
||||
original_options = {
|
||||
"dict_1": {"a": 1, "b": -1},
|
||||
"dict_2": {"a": 1, "b": -1},
|
||||
}
|
||||
new_weights = Generate.update_weights(
|
||||
original_options,
|
||||
{
|
||||
"+dict_1": {"a": -2, "b": 2},
|
||||
"-dict_2": {"a": 1, "b": 2},
|
||||
},
|
||||
"Tested",
|
||||
"",
|
||||
)
|
||||
self.assertEqual(new_weights["dict_1"]["a"], -1)
|
||||
self.assertEqual(new_weights["dict_1"]["b"], 1)
|
||||
self.assertEqual(new_weights["dict_2"]["a"], 0)
|
||||
self.assertEqual(new_weights["dict_2"]["b"], -3)
|
||||
self.assertIn("a", new_weights["dict_2"])
|
||||
|
||||
1336
test/general/test_rule_builder.py
Normal file
1336
test/general/test_rule_builder.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -70,13 +70,13 @@ if __name__ == "__main__":
|
||||
empty_file = str(Path(tempdir) / "empty")
|
||||
open(empty_file, "w").close()
|
||||
sys.argv += ["--config_override", empty_file] # tests #5541
|
||||
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
||||
multis = [["APQuest"], ["Temp World"], ["APQuest", "Temp World"]]
|
||||
p1_games: list[str] = []
|
||||
data_paths: list[Path | None] = []
|
||||
rooms: list[str] = []
|
||||
multidata: Path | None
|
||||
|
||||
copy_world("VVVVVV", "Temp World")
|
||||
copy_world("APQuest", "Temp World")
|
||||
try:
|
||||
for n, games in enumerate(multis, 1):
|
||||
print(f"Generating [{n}] {', '.join(games)} offline")
|
||||
|
||||
@@ -20,7 +20,7 @@ def copy(src: str, dst: str) -> None:
|
||||
src_cls = AutoWorldRegister.world_types[src]
|
||||
src_folder = Path(src_cls.__file__).parent
|
||||
worlds_folder = src_folder.parent
|
||||
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
|
||||
if (not src_cls.__file__.endswith(("__init__.py", "world.py")) or not src_folder.is_dir()
|
||||
or not (worlds_folder / "generic").is_dir()):
|
||||
raise ValueError(f"Unsupported layout for copy_world from {src}")
|
||||
dst_folder = worlds_folder / dst_folder_name
|
||||
@@ -28,11 +28,14 @@ def copy(src: str, dst: str) -> None:
|
||||
raise ValueError(f"Destination {dst_folder} already exists")
|
||||
shutil.copytree(src_folder, dst_folder)
|
||||
_new_worlds[dst] = str(dst_folder)
|
||||
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
|
||||
contents = f.read()
|
||||
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
|
||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
for potential_world_class_file in ("__init__.py", "world.py"):
|
||||
with open(dst_folder / potential_world_class_file, "r", encoding="utf-8-sig") as f:
|
||||
contents = f.read()
|
||||
r_src = re.escape(src)
|
||||
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + r_src + r'[\'"]', f'game = "{dst}"', contents)
|
||||
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
|
||||
f.write(contents)
|
||||
|
||||
|
||||
def delete(name: str) -> None:
|
||||
|
||||
@@ -25,31 +25,41 @@ class TestGenerateYamlTemplates(unittest.TestCase):
|
||||
if "World: with colon" in worlds.AutoWorld.AutoWorldRegister.world_types:
|
||||
del worlds.AutoWorld.AutoWorldRegister.world_types["World: with colon"]
|
||||
|
||||
|
||||
def test_name_with_colon(self) -> None:
|
||||
from Options import generate_yaml_templates
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.AutoWorld import World
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
|
||||
class WebWorldWithColon(WebWorld):
|
||||
options_presets = {
|
||||
"Generic": {
|
||||
"progression_balancing": "disabled",
|
||||
"accessibility": "minimal",
|
||||
}
|
||||
}
|
||||
|
||||
class WorldWithColon(World):
|
||||
game = "World: with colon"
|
||||
item_name_to_id = {}
|
||||
location_name_to_id = {}
|
||||
web = WebWorldWithColon()
|
||||
|
||||
AutoWorldRegister.world_types = {WorldWithColon.game: WorldWithColon}
|
||||
with TemporaryDirectory(f"archipelago_{__name__}") as temp_dir:
|
||||
generate_yaml_templates(temp_dir)
|
||||
path: Path
|
||||
for path in Path(temp_dir).iterdir():
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertTrue(path.suffix == ".yaml")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
try:
|
||||
data = parse_yaml(f)
|
||||
except:
|
||||
f.seek(0)
|
||||
print(f"Error in {path.name}:\n{f.read()}")
|
||||
raise
|
||||
self.assertIn("game", data)
|
||||
self.assertIn(":", data["game"])
|
||||
self.assertIn(data["game"], data)
|
||||
self.assertIsInstance(data[data["game"]], dict)
|
||||
for path in Path(temp_dir).rglob("*"):
|
||||
if path.is_file():
|
||||
self.assertTrue(path.suffix == ".yaml")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
try:
|
||||
data = parse_yaml(f)
|
||||
except:
|
||||
f.seek(0)
|
||||
print(f"Error in {path.name}:\n{f.read()}")
|
||||
raise
|
||||
self.assertIn("game", data)
|
||||
self.assertIn(":", data["game"])
|
||||
self.assertIn(data["game"], data)
|
||||
self.assertIsInstance(data[data["game"]], dict)
|
||||
|
||||
@@ -2,8 +2,8 @@ description: Almost blank test yaml
|
||||
name: Player{NUMBER}
|
||||
|
||||
game:
|
||||
Timespinner: 1 # what else
|
||||
APQuest: 1 # what else
|
||||
requires:
|
||||
version: 0.2.6
|
||||
Timespinner: {}
|
||||
APQuest: {}
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
from uuid import UUID, uuid4, uuid5
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from WebHostLib.customserver import set_up_logging, tear_down_logging
|
||||
from . import TestBase
|
||||
|
||||
|
||||
def _cleanup_logger(room_id: UUID) -> None:
|
||||
from Utils import user_path
|
||||
tear_down_logging(room_id)
|
||||
try:
|
||||
os.unlink(user_path("logs", f"{room_id}.txt"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestHostFakeRoom(TestBase):
|
||||
room_id: UUID
|
||||
log_filename: str
|
||||
@@ -39,7 +50,7 @@ class TestHostFakeRoom(TestBase):
|
||||
|
||||
try:
|
||||
os.unlink(self.log_filename)
|
||||
except FileNotFoundError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_display_log_missing_full(self) -> None:
|
||||
@@ -191,3 +202,27 @@ class TestHostFakeRoom(TestBase):
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
|
||||
self.assertNotIn("/help", (command.commandtext for command in commands))
|
||||
|
||||
def test_logger_teardown(self) -> None:
|
||||
"""Verify that room loggers are removed from the global logging manager."""
|
||||
from WebHostLib.customserver import tear_down_logging
|
||||
room_id = uuid4()
|
||||
self.addCleanup(_cleanup_logger, room_id)
|
||||
set_up_logging(room_id)
|
||||
self.assertIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
|
||||
tear_down_logging(room_id)
|
||||
self.assertNotIn(f"RoomLogger {room_id}", logging.Logger.manager.loggerDict)
|
||||
|
||||
def test_handler_teardown(self) -> None:
|
||||
"""Verify that handlers for room loggers are closed by tear_down_logging."""
|
||||
from WebHostLib.customserver import tear_down_logging
|
||||
room_id = uuid4()
|
||||
self.addCleanup(_cleanup_logger, room_id)
|
||||
logger = set_up_logging(room_id)
|
||||
handlers = logger.handlers[:]
|
||||
self.assertGreater(len(handlers), 0)
|
||||
|
||||
tear_down_logging(room_id)
|
||||
for handler in handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
self.assertTrue(handler.stream is None or handler.stream.closed)
|
||||
|
||||
@@ -2,7 +2,7 @@ import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds import AutoWorldRegister
|
||||
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet
|
||||
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet, Visibility
|
||||
|
||||
|
||||
class TestOptionPresets(unittest.TestCase):
|
||||
@@ -19,6 +19,9 @@ class TestOptionPresets(unittest.TestCase):
|
||||
# pass in all plando options in case a preset wants to require certain plando options
|
||||
# for some reason
|
||||
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
|
||||
if not (Visibility.complex_ui in option.visibility or Visibility.simple_ui in option.visibility):
|
||||
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' is not "
|
||||
f"visible in any supported UI.")
|
||||
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
|
||||
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
||||
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
||||
|
||||
82
typings/kivy/clock.pyi
Normal file
82
typings/kivy/clock.pyi
Normal file
@@ -0,0 +1,82 @@
|
||||
from _typeshed import Incomplete
|
||||
from kivy._clock import (
|
||||
ClockEvent as ClockEvent,
|
||||
ClockNotRunningError as ClockNotRunningError,
|
||||
CyClockBase as CyClockBase,
|
||||
CyClockBaseFree as CyClockBaseFree,
|
||||
FreeClockEvent as FreeClockEvent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Clock",
|
||||
"ClockNotRunningError",
|
||||
"ClockEvent",
|
||||
"FreeClockEvent",
|
||||
"CyClockBase",
|
||||
"CyClockBaseFree",
|
||||
"triggered",
|
||||
"ClockBaseBehavior",
|
||||
"ClockBaseInterruptBehavior",
|
||||
"ClockBaseInterruptFreeBehavior",
|
||||
"ClockBase",
|
||||
"ClockBaseInterrupt",
|
||||
"ClockBaseFreeInterruptAll",
|
||||
"ClockBaseFreeInterruptOnly",
|
||||
"mainthread",
|
||||
]
|
||||
|
||||
class ClockBaseBehavior:
|
||||
MIN_SLEEP: float
|
||||
SLEEP_UNDERSHOOT: Incomplete
|
||||
def __init__(self, async_lib: str = "asyncio", **kwargs) -> None: ...
|
||||
def init_async_lib(self, lib) -> None: ...
|
||||
@property
|
||||
def frametime(self): ...
|
||||
@property
|
||||
def frames(self): ...
|
||||
@property
|
||||
def frames_displayed(self): ...
|
||||
def usleep(self, microseconds) -> None: ...
|
||||
def idle(self): ...
|
||||
async def async_idle(self): ...
|
||||
def tick(self) -> None: ...
|
||||
async def async_tick(self) -> None: ...
|
||||
def pre_idle(self) -> None: ...
|
||||
def post_idle(self, ts, current): ...
|
||||
def tick_draw(self) -> None: ...
|
||||
def get_fps(self): ...
|
||||
def get_rfps(self): ...
|
||||
def get_time(self): ...
|
||||
def get_boottime(self): ...
|
||||
time: Incomplete
|
||||
def handle_exception(self, e) -> None: ...
|
||||
|
||||
class ClockBaseInterruptBehavior(ClockBaseBehavior):
|
||||
interupt_next_only: bool
|
||||
def __init__(self, interupt_next_only: bool = False, **kwargs) -> None: ...
|
||||
def init_async_lib(self, lib) -> None: ...
|
||||
def usleep(self, microseconds) -> None: ...
|
||||
async def async_usleep(self, microseconds) -> None: ...
|
||||
def on_schedule(self, event) -> None: ...
|
||||
def idle(self): ...
|
||||
async def async_idle(self): ...
|
||||
|
||||
class ClockBaseInterruptFreeBehavior(ClockBaseInterruptBehavior):
|
||||
def __init__(self, **kwargs) -> None: ...
|
||||
def on_schedule(self, event): ...
|
||||
|
||||
class ClockBase(ClockBaseBehavior, CyClockBase):
|
||||
def __init__(self, **kwargs) -> None: ...
|
||||
def usleep(self, microseconds) -> None: ...
|
||||
|
||||
class ClockBaseInterrupt(ClockBaseInterruptBehavior, CyClockBase): ...
|
||||
class ClockBaseFreeInterruptAll(ClockBaseInterruptFreeBehavior, CyClockBaseFree): ...
|
||||
|
||||
class ClockBaseFreeInterruptOnly(ClockBaseInterruptFreeBehavior, CyClockBaseFree):
|
||||
def idle(self): ...
|
||||
async def async_idle(self): ...
|
||||
|
||||
def mainthread(func): ...
|
||||
def triggered(timeout: int = 0, interval: bool = False): ...
|
||||
|
||||
Clock: ClockBase
|
||||
@@ -1,14 +1,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
import enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
|
||||
from typing import (TYPE_CHECKING, Any, ClassVar, Dict, Generic, Iterable,
|
||||
Optional, Sequence, Tuple, TypeGuard, TypeVar, Union)
|
||||
|
||||
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from SNIClient import SNIContext
|
||||
|
||||
SNES_READ_CHUNK_SIZE = 2048
|
||||
"""
|
||||
note: SNI v0.0.101 currently has a bug where reads from
|
||||
RetroArch >2048 bytes will only return the last ~2048 bytes read.
|
||||
https://github.com/alttpo/sni/issues/51
|
||||
"""
|
||||
|
||||
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
|
||||
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
|
||||
components.append(component)
|
||||
@@ -91,3 +103,119 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
|
||||
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
|
||||
""" override this with code to handle packages from the server """
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, order=True)
|
||||
class Read:
|
||||
""" snes memory read - address and size in bytes """
|
||||
address: int
|
||||
size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MemRead:
|
||||
location: Read
|
||||
data: bytes
|
||||
|
||||
|
||||
_T_Enum = TypeVar("_T_Enum", bound=enum.Enum)
|
||||
|
||||
|
||||
class SnesData(Generic[_T_Enum]):
|
||||
_ranges: Sequence[_MemRead]
|
||||
""" sorted by address """
|
||||
|
||||
def __init__(self, ranges: Sequence[tuple[Read, bytes]]) -> None:
|
||||
self._ranges = [_MemRead(r, d) for r, d in ranges]
|
||||
|
||||
def get(self, read: _T_Enum) -> bytes:
|
||||
assert isinstance(read.value, Read), read.value
|
||||
address = read.value.address
|
||||
index = bisect_right(self._ranges, address, key=lambda r: r.location.address) - 1
|
||||
assert index >= 0, (self._ranges, read.value)
|
||||
mem_read = self._ranges[index]
|
||||
sub_index = address - mem_read.location.address
|
||||
return mem_read.data[sub_index:sub_index + read.value.size]
|
||||
|
||||
|
||||
class SnesReader(Generic[_T_Enum]):
|
||||
"""
|
||||
how to use:
|
||||
```
|
||||
from enum import Enum
|
||||
from worlds.AutoSNIClient import Read, SNIClient, SnesReader
|
||||
|
||||
class MyGameMemory(Enum):
|
||||
game_mode = Read(WRAM_START + 0x0998, 1)
|
||||
send_queue = Read(SEND_QUEUE_START, 8 * 127)
|
||||
...
|
||||
|
||||
snes_reader = SnesReader(MyGameMemory)
|
||||
|
||||
snes_data = await snes_reader.read(ctx)
|
||||
if snes_data is None:
|
||||
snes_logger.info("error reading from snes")
|
||||
return
|
||||
|
||||
game_mode = snes_data.get(MyGameMemory.game_mode)
|
||||
```
|
||||
"""
|
||||
_ranges: Sequence[Read]
|
||||
""" sorted by address """
|
||||
|
||||
def __init__(self, reads: type[_T_Enum]) -> None:
|
||||
self._ranges = self._make_ranges(reads)
|
||||
|
||||
@staticmethod
|
||||
def _make_ranges(reads: type[enum.Enum]) -> Sequence[Read]:
|
||||
|
||||
unprocessed_reads: list[Read] = []
|
||||
for e in reads:
|
||||
assert isinstance(e.value, Read), (reads.__name__, e, e.value)
|
||||
unprocessed_reads.append(e.value)
|
||||
unprocessed_reads.sort()
|
||||
|
||||
ranges: list[Read] = []
|
||||
for read in unprocessed_reads:
|
||||
# v end of the previous range
|
||||
if len(ranges) == 0 or read.address - (ranges[-1].address + ranges[-1].size) > 255:
|
||||
ranges.append(read)
|
||||
else: # combine with previous range
|
||||
chunk_address = ranges[-1].address
|
||||
assert read.address >= chunk_address, "sort() didn't work? or something"
|
||||
original_chunk_size = ranges[-1].size
|
||||
new_size = max((read.address + read.size) - chunk_address,
|
||||
original_chunk_size)
|
||||
ranges[-1] = Read(chunk_address, new_size)
|
||||
logging.debug(f"{len(ranges)=} {max(r.size for r in ranges)=}")
|
||||
return ranges
|
||||
|
||||
async def read(self, ctx: "SNIContext") -> SnesData[_T_Enum] | None:
|
||||
"""
|
||||
returns `None` if reading fails,
|
||||
otherwise returns the data for the registered `Enum`
|
||||
"""
|
||||
from SNIClient import snes_read
|
||||
|
||||
reads: list[tuple[Read, bytes]] = []
|
||||
for r in self._ranges:
|
||||
if r.size < SNES_READ_CHUNK_SIZE: # most common
|
||||
response = await snes_read(ctx, r.address, r.size)
|
||||
if response is None:
|
||||
return None
|
||||
reads.append((r, response))
|
||||
else: # big read
|
||||
# Problems were reported with big reads,
|
||||
# so we chunk it into smaller pieces.
|
||||
read_so_far = 0
|
||||
collection: list[bytes] = []
|
||||
while read_so_far < r.size:
|
||||
remaining_size = r.size - read_so_far
|
||||
chunk_size = min(SNES_READ_CHUNK_SIZE, remaining_size)
|
||||
response = await snes_read(ctx, r.address + read_so_far, chunk_size)
|
||||
if response is None:
|
||||
return None
|
||||
collection.append(response)
|
||||
read_so_far += chunk_size
|
||||
reads.append((r, b"".join(collection)))
|
||||
return SnesData(reads)
|
||||
|
||||
@@ -5,17 +5,18 @@ import logging
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from random import Random
|
||||
from dataclasses import make_dataclass
|
||||
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
|
||||
from typing import (Any, ClassVar, Dict, FrozenSet, List, Optional, Self, Set, TextIO, Tuple,
|
||||
TYPE_CHECKING, Type, Union)
|
||||
|
||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||
from BaseClasses import CollectionState
|
||||
from BaseClasses import CollectionState, Entrance
|
||||
from rule_builder.rules import CustomRuleRegister, Rule
|
||||
from Utils import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||
from BaseClasses import CollectionRule, Item, Location, MultiWorld, Region, Tutorial
|
||||
from NetUtils import GamesPackage, MultiData
|
||||
from settings import Group
|
||||
|
||||
@@ -47,27 +48,31 @@ class AutoWorldRegister(type):
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
|
||||
if "web" in dct:
|
||||
assert isinstance(dct["web"], WebWorld), "WebWorld has to be instantiated."
|
||||
# filter out any events
|
||||
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
|
||||
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
|
||||
# build reverse lookups
|
||||
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
|
||||
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
|
||||
|
||||
# build rest
|
||||
dct["item_names"] = frozenset(dct["item_name_to_id"])
|
||||
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("item_name_groups", {}).items()}
|
||||
dct["item_name_groups"]["Everything"] = dct["item_names"]
|
||||
|
||||
dct["location_names"] = frozenset(dct["location_name_to_id"])
|
||||
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("location_name_groups", {}).items()}
|
||||
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
|
||||
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
|
||||
|
||||
# move away from get_required_client_version function
|
||||
if "game" in dct:
|
||||
assert "item_name_to_id" in dct, f"{name}: item_name_to_id is required"
|
||||
assert "location_name_to_id" in dct, f"{name}: location_name_to_id is required"
|
||||
|
||||
# filter out any events
|
||||
dct["item_name_to_id"] = {name: id for name, id in dct["item_name_to_id"].items() if id}
|
||||
dct["location_name_to_id"] = {name: id for name, id in dct["location_name_to_id"].items() if id}
|
||||
# build reverse lookups
|
||||
dct["item_id_to_name"] = {code: name for name, code in dct["item_name_to_id"].items()}
|
||||
dct["location_id_to_name"] = {code: name for name, code in dct["location_name_to_id"].items()}
|
||||
|
||||
# build rest
|
||||
dct["item_names"] = frozenset(dct["item_name_to_id"])
|
||||
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("item_name_groups", {}).items()}
|
||||
dct["item_name_groups"]["Everything"] = dct["item_names"]
|
||||
|
||||
dct["location_names"] = frozenset(dct["location_name_to_id"])
|
||||
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
|
||||
in dct.get("location_name_groups", {}).items()}
|
||||
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
|
||||
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
|
||||
|
||||
# move away from get_required_client_version function
|
||||
assert "get_required_client_version" not in dct, f"{name}: required_client_version is an attribute now"
|
||||
# set minimum required_client_version from bases
|
||||
if "required_client_version" in dct and bases:
|
||||
@@ -173,7 +178,8 @@ def _timed_call(method: Callable[..., Any], *args: Any,
|
||||
|
||||
|
||||
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
|
||||
method = getattr(multiworld.worlds[player], method_name)
|
||||
world = multiworld.worlds[player]
|
||||
method = getattr(world, method_name)
|
||||
try:
|
||||
ret = _timed_call(method, *args, multiworld=multiworld, player=player)
|
||||
except Exception as e:
|
||||
@@ -184,6 +190,10 @@ def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args:
|
||||
logging.error(message)
|
||||
raise e
|
||||
else:
|
||||
# Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called
|
||||
# Can be removed once dependency system is improved
|
||||
if method_name == "set_rules" and hasattr(world, "register_rule_builder_dependencies"):
|
||||
call_single(multiworld, "register_rule_builder_dependencies", player)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -224,7 +234,7 @@ class WebWorld(metaclass=WebWorldRegister):
|
||||
tutorials: List["Tutorial"]
|
||||
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide."""
|
||||
|
||||
theme = "grass"
|
||||
theme: str = "grass"
|
||||
"""Choose a theme for you /game/* pages.
|
||||
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
|
||||
|
||||
@@ -484,7 +494,14 @@ class World(metaclass=AutoWorldRegister):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
"""Called when the item pool needs to be filled with additional items to match location count."""
|
||||
"""
|
||||
Called when the item pool needs to be filled with additional items to match location count.
|
||||
|
||||
Any returned item name must be for a "repeatable" item, i.e. one that it's okay to generate arbitrarily many of.
|
||||
For most worlds this will be one or more of your filler items, but the classification of these items
|
||||
does not need to be ItemClassification.filler.
|
||||
The item name returned can be for a trap, useful, and/or progression item as long as it's repeatable.
|
||||
"""
|
||||
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
|
||||
return self.random.choice(tuple(self.item_name_to_id.keys()))
|
||||
|
||||
@@ -538,6 +555,10 @@ class World(metaclass=AutoWorldRegister):
|
||||
return True
|
||||
return False
|
||||
|
||||
def reached_region(self, state: "CollectionState", region: "Region") -> None:
|
||||
"""Called when a region is newly reachable by the state."""
|
||||
pass
|
||||
|
||||
# following methods should not need to be overridden.
|
||||
def create_filler(self) -> "Item":
|
||||
return self.create_item(self.get_filler_item_name())
|
||||
@@ -586,6 +607,64 @@ class World(metaclass=AutoWorldRegister):
|
||||
res["checksum"] = data_package_checksum(res)
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def get_rule_cls(cls, name: str) -> type[Rule[Self]]:
|
||||
"""Returns the world-registered or default rule with the given name"""
|
||||
return CustomRuleRegister.get_rule_cls(cls.game, name)
|
||||
|
||||
@classmethod
|
||||
def rule_from_dict(cls, data: Mapping[str, Any]) -> Rule[Self]:
|
||||
"""Create a rule instance from a serialized dict representation"""
|
||||
name = data.get("rule", "")
|
||||
rule_class = cls.get_rule_cls(name)
|
||||
return rule_class.from_dict(data, cls)
|
||||
|
||||
def set_rule(self, spot: Location | Entrance, rule: CollectionRule | Rule[Any]) -> None:
|
||||
"""Sets an access rule for a location or entrance"""
|
||||
if isinstance(rule, Rule):
|
||||
rule = rule.resolve(self)
|
||||
self.register_rule_dependencies(rule)
|
||||
if isinstance(spot, Entrance):
|
||||
self._register_rule_indirects(rule, spot)
|
||||
spot.access_rule = rule
|
||||
|
||||
def set_completion_rule(self, rule: CollectionRule | Rule[Any]) -> None:
|
||||
"""Set the completion rule for this world"""
|
||||
if isinstance(rule, Rule):
|
||||
rule = rule.resolve(self)
|
||||
self.register_rule_dependencies(rule)
|
||||
self.multiworld.completion_condition[self.player] = rule
|
||||
|
||||
def create_entrance(
|
||||
self,
|
||||
from_region: Region,
|
||||
to_region: Region,
|
||||
rule: CollectionRule | Rule[Any] | None = None,
|
||||
name: str | None = None,
|
||||
force_creation: bool = False,
|
||||
) -> Entrance | None:
|
||||
"""Try to create an entrance between regions with the given rule,
|
||||
skipping it if the rule resolves to False (unless force_creation is True)"""
|
||||
if rule is not None and isinstance(rule, Rule):
|
||||
rule = rule.resolve(self)
|
||||
if rule.always_false and not force_creation:
|
||||
return None
|
||||
self.register_rule_dependencies(rule)
|
||||
|
||||
entrance = from_region.connect(to_region, name, rule=rule)
|
||||
if rule and isinstance(rule, Rule.Resolved):
|
||||
self._register_rule_indirects(rule, entrance)
|
||||
return entrance
|
||||
|
||||
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
|
||||
"""Hook for registering dependencies when a rule is assigned for this world"""
|
||||
pass
|
||||
|
||||
def _register_rule_indirects(self, resolved_rule: Rule.Resolved, entrance: Entrance) -> None:
|
||||
if self.explicit_indirect_conditions:
|
||||
for indirect_region in resolved_rule.region_dependencies().keys():
|
||||
self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance)
|
||||
|
||||
|
||||
# any methods attached to this can be used as part of CollectionState,
|
||||
# please use a prefix as all of them get clobbered together
|
||||
|
||||
@@ -21,6 +21,10 @@ if TYPE_CHECKING:
|
||||
from Utils import Version
|
||||
|
||||
|
||||
class ImproperlyConfiguredAutoPatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AutoPatchRegister(abc.ABCMeta):
|
||||
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||
file_endings: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||
@@ -30,8 +34,28 @@ class AutoPatchRegister(abc.ABCMeta):
|
||||
new_class = super().__new__(mcs, name, bases, dct)
|
||||
if "game" in dct:
|
||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
||||
if not dct["patch_file_ending"]:
|
||||
raise Exception(f"Need an expected file ending for {name}")
|
||||
|
||||
if not callable(getattr(new_class, "patch", None)):
|
||||
raise ImproperlyConfiguredAutoPatchError(
|
||||
f"Container {new_class} uses metaclass AutoPatchRegister, but does not have a patch method defined."
|
||||
)
|
||||
|
||||
patch_file_ending = dct.get("patch_file_ending")
|
||||
if patch_file_ending == ".zip":
|
||||
raise ImproperlyConfiguredAutoPatchError(
|
||||
f'Auto patch container {new_class} uses file ending ".zip", which is not allowed.'
|
||||
)
|
||||
if patch_file_ending is None:
|
||||
raise ImproperlyConfiguredAutoPatchError(
|
||||
f"Need an expected file ending for auto patch container {new_class}"
|
||||
)
|
||||
|
||||
existing_handler = AutoPatchRegister.file_endings.get(patch_file_ending)
|
||||
if existing_handler:
|
||||
raise ImproperlyConfiguredAutoPatchError(
|
||||
f"Two auto patch containers are using the same file extension: {new_class}, {existing_handler}"
|
||||
)
|
||||
|
||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
||||
return new_class
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import weakref
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Callable, List, Iterable, Tuple
|
||||
|
||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running
|
||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
@@ -204,6 +204,18 @@ def install_apworld(apworld_path: str = "") -> None:
|
||||
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
|
||||
|
||||
|
||||
def export_datapackage() -> None:
|
||||
import json
|
||||
|
||||
from worlds import network_data_package
|
||||
|
||||
path = user_path("datapackage_export.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(network_data_package, f, indent=4)
|
||||
|
||||
open_file(path)
|
||||
|
||||
|
||||
components: List[Component] = [
|
||||
# Launcher
|
||||
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
||||
@@ -213,6 +225,8 @@ components: List[Component] = [
|
||||
description="Host a generated multiworld on your computer."),
|
||||
Component('Generate', 'Generate', cli=True,
|
||||
description="Generate a multiworld with the YAMLs in the players folder."),
|
||||
Component("Options Creator", "OptionsCreator", "ArchipelagoOptionsCreator", component_type=Type.TOOL,
|
||||
description="Visual creator for Archipelago option files."),
|
||||
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
|
||||
description="Install an APWorld to play games not included with Archipelago by default."),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
||||
@@ -230,8 +244,11 @@ components: List[Component] = [
|
||||
Component('Zillion Client', 'ZillionClient',
|
||||
file_identifier=SuffixIdentifier('.apzl')),
|
||||
|
||||
#MegaMan Battle Network 3
|
||||
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3'))
|
||||
# MegaMan Battle Network 3
|
||||
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3')),
|
||||
|
||||
Component("Export Datapackage", func=export_datapackage, component_type=Type.TOOL,
|
||||
description="Write item/location data for installed worlds to a file and open it."),
|
||||
]
|
||||
|
||||
|
||||
@@ -262,6 +279,10 @@ if not is_frozen():
|
||||
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
|
||||
if not worldtype.zip_path]
|
||||
|
||||
global_apignores = read_apignore(local_path("data", "GLOBAL.apignore"))
|
||||
if not global_apignores:
|
||||
raise RuntimeError("Could not read global apignore file for build component")
|
||||
|
||||
apworlds_folder = os.path.join("build", "apworlds")
|
||||
os.makedirs(apworlds_folder, exist_ok=True)
|
||||
for worldname, worldtype in games:
|
||||
@@ -275,11 +296,11 @@ if not is_frozen():
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
assert "game" in manifest, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it "
|
||||
"does not define a \"game\"."
|
||||
)
|
||||
assert manifest["game"] == worldtype.game, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
|
||||
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
|
||||
)
|
||||
else:
|
||||
@@ -289,18 +310,17 @@ if not is_frozen():
|
||||
apworld = APWorldContainer(str(zip_path))
|
||||
apworld.game = worldtype.game
|
||||
manifest.update(apworld.get_manifest())
|
||||
apworld.manifest_path = f"{file_name}/archipelago.json"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
for path in pathlib.Path(world_directory).rglob("*"):
|
||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
|
||||
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
|
||||
continue
|
||||
if not relative_path.endswith("archipelago.json"):
|
||||
zf.write(path, relative_path)
|
||||
apworld.manifest_path = os.path.join(file_name, "archipelago.json")
|
||||
|
||||
local_ignores = read_apignore(pathlib.Path(world_directory, ".apignore"))
|
||||
apignores = global_apignores + local_ignores if local_ignores else global_apignores
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||
for file in apignores.match_tree_files(world_directory, negate=True):
|
||||
zf.write(pathlib.Path(world_directory, file), pathlib.Path(file_name, file))
|
||||
|
||||
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
||||
open_folder(apworlds_folder)
|
||||
|
||||
|
||||
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
|
||||
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
|
||||
description="Build APWorlds from loose-file world folders."))
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
import importlib.abc
|
||||
import importlib.machinery
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
import zipimport
|
||||
import time
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import List, Sequence
|
||||
from zipfile import BadZipFile
|
||||
|
||||
from NetUtils import DataPackage
|
||||
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
|
||||
from Utils import local_path, user_path, Version, version_tuple, tuplize_version, messagebox
|
||||
|
||||
local_folder = os.path.dirname(__file__)
|
||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
||||
@@ -20,14 +23,14 @@ try:
|
||||
except OSError: # can't access/write?
|
||||
user_folder = None
|
||||
|
||||
__all__ = {
|
||||
__all__ = [
|
||||
"network_data_package",
|
||||
"AutoWorldRegister",
|
||||
"world_sources",
|
||||
"local_folder",
|
||||
"user_folder",
|
||||
"failed_world_loads",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
failed_world_loads: List[str] = []
|
||||
@@ -53,21 +56,7 @@ class WorldSource:
|
||||
def load(self) -> bool:
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
if self.is_zip:
|
||||
importer = zipimport.zipimporter(self.resolved_path)
|
||||
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
|
||||
assert spec, f"{self.path} is not a loadable module"
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
||||
importer.exec_module(mod)
|
||||
else:
|
||||
importlib.import_module(f".{self.path}", "worlds")
|
||||
importlib.import_module(f".{Path(self.path).stem}", "worlds")
|
||||
self.time_taken = time.perf_counter()-start
|
||||
return True
|
||||
|
||||
@@ -112,7 +101,6 @@ for world_source in world_sources:
|
||||
else:
|
||||
world_source.load()
|
||||
|
||||
|
||||
from .AutoWorld import AutoWorldRegister
|
||||
|
||||
for world_source in world_sources:
|
||||
@@ -157,6 +145,15 @@ if apworlds:
|
||||
logging.error(e)
|
||||
else:
|
||||
raise e
|
||||
except BadZipFile as e:
|
||||
err_message = (f"The world source {apworld_source.resolved_path} is not a valid zip. "
|
||||
"It is likely either corrupted, or was packaged incorrectly.")
|
||||
|
||||
if sys.stdout:
|
||||
raise RuntimeError(err_message) from e
|
||||
else:
|
||||
messagebox("Couldn't load worlds", err_message, error=True)
|
||||
sys.exit(1)
|
||||
|
||||
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
|
||||
fail_world(apworld.game,
|
||||
@@ -174,6 +171,16 @@ if apworlds:
|
||||
core_compatible.sort(
|
||||
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
|
||||
reverse=True)
|
||||
|
||||
apworld_module_specs = {}
|
||||
class APWorldModuleFinder(importlib.abc.MetaPathFinder):
|
||||
def find_spec(
|
||||
self, fullname: str, _path: Sequence[str] | None, _target: ModuleType = None
|
||||
) -> importlib.machinery.ModuleSpec | None:
|
||||
return apworld_module_specs.get(fullname)
|
||||
|
||||
sys.meta_path.insert(0, APWorldModuleFinder())
|
||||
|
||||
for apworld_source, apworld in core_compatible:
|
||||
if apworld.game and apworld.game in AutoWorldRegister.world_types:
|
||||
fail_world(apworld.game,
|
||||
@@ -181,6 +188,12 @@ if apworlds:
|
||||
f"as its game {apworld.game} is already loaded.",
|
||||
add_as_failed_to_load=False)
|
||||
else:
|
||||
importer = zipimport.zipimporter(apworld_source.resolved_path)
|
||||
world_name = Path(apworld.path).stem
|
||||
|
||||
spec = importer.find_spec(f"worlds.{world_name}")
|
||||
apworld_module_specs[f"worlds.{world_name}"] = spec
|
||||
|
||||
apworld_source.load()
|
||||
if apworld.game in AutoWorldRegister.world_types:
|
||||
# world could fail to load at this point
|
||||
|
||||
@@ -9,7 +9,7 @@ from collections import Counter
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Dict, Generator, Iterable, List, Set, Tuple, Union, final
|
||||
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
from .constants import (
|
||||
IS_PLACEHOLDER,
|
||||
|
||||
@@ -4,11 +4,11 @@ from typing import Dict, Iterable, List, Optional, Set, Tuple, Union
|
||||
|
||||
from worlds._sc2common.bot import logger
|
||||
|
||||
from s2clientprotocol import debug_pb2 as debug_pb
|
||||
from s2clientprotocol import query_pb2 as query_pb
|
||||
from s2clientprotocol import raw_pb2 as raw_pb
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from s2clientprotocol import spatial_pb2 as spatial_pb
|
||||
from .proto import debug_pb2 as debug_pb
|
||||
from .proto import query_pb2 as query_pb
|
||||
from .proto import raw_pb2 as raw_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
from .proto import spatial_pb2 as spatial_pb
|
||||
|
||||
from .data import ActionResult, ChatChannel, Race, Result, Status
|
||||
from .game_data import AbilityData, GameData
|
||||
|
||||
@@ -2,7 +2,7 @@ import platform
|
||||
from pathlib import Path
|
||||
|
||||
from worlds._sc2common.bot import logger
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
from .player import Computer
|
||||
from .protocol import Protocol
|
||||
|
||||
@@ -7,11 +7,11 @@ https://github.com/Blizzard/s2client-api/blob/d9ba0a33d6ce9d233c2a4ee988360c188f
|
||||
"""
|
||||
import enum
|
||||
|
||||
from s2clientprotocol import common_pb2 as common_pb
|
||||
from s2clientprotocol import data_pb2 as data_pb
|
||||
from s2clientprotocol import error_pb2 as error_pb
|
||||
from s2clientprotocol import raw_pb2 as raw_pb
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import common_pb2 as common_pb
|
||||
from .proto import data_pb2 as data_pb
|
||||
from .proto import error_pb2 as error_pb
|
||||
from .proto import raw_pb2 as raw_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
CreateGameError = enum.Enum("CreateGameError", sc_pb.ResponseCreateGame.Error.items())
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import mpyq
|
||||
import portpicker
|
||||
from aiohttp import ClientSession, ClientWebSocketResponse
|
||||
from worlds._sc2common.bot import logger
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
from .bot_ai import BotAI
|
||||
from .client import Client
|
||||
|
||||
@@ -5,7 +5,7 @@ import math
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Iterable, List, Set, Tuple, Union
|
||||
|
||||
from s2clientprotocol import common_pb2 as common_pb
|
||||
from .proto import common_pb2 as common_pb
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .unit import Unit
|
||||
|
||||
0
worlds/_sc2common/bot/proto/__init__.py
Normal file
0
worlds/_sc2common/bot/proto/__init__.py
Normal file
50
worlds/_sc2common/bot/proto/common_pb2.py
Normal file
50
worlds/_sc2common/bot/proto/common_pb2.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/common.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/common.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ds2clientprotocol/common.proto\x12\x0eSC2APIProtocol\">\n\x10\x41vailableAbility\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x16\n\x0erequires_point\x18\x02 \x01(\x08\"X\n\tImageData\x12\x16\n\x0e\x62its_per_pixel\x18\x01 \x01(\x05\x12%\n\x04size\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Size2DI\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"\x1e\n\x06PointI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05\"T\n\nRectangleI\x12\"\n\x02p0\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\"\n\x02p1\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\x1f\n\x07Point2D\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\"(\n\x05Point\x12\t\n\x01x\x18\x01 \x01(\x02\x12\t\n\x01y\x18\x02 \x01(\x02\x12\t\n\x01z\x18\x03 \x01(\x02\"\x1f\n\x07Size2DI\x12\t\n\x01x\x18\x01 \x01(\x05\x12\t\n\x01y\x18\x02 \x01(\x05*A\n\x04Race\x12\n\n\x06NoRace\x10\x00\x12\n\n\x06Terran\x10\x01\x12\x08\n\x04Zerg\x10\x02\x12\x0b\n\x07Protoss\x10\x03\x12\n\n\x06Random\x10\x04')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.common_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_RACE']._serialized_start=429
|
||||
_globals['_RACE']._serialized_end=494
|
||||
_globals['_AVAILABLEABILITY']._serialized_start=49
|
||||
_globals['_AVAILABLEABILITY']._serialized_end=111
|
||||
_globals['_IMAGEDATA']._serialized_start=113
|
||||
_globals['_IMAGEDATA']._serialized_end=201
|
||||
_globals['_POINTI']._serialized_start=203
|
||||
_globals['_POINTI']._serialized_end=233
|
||||
_globals['_RECTANGLEI']._serialized_start=235
|
||||
_globals['_RECTANGLEI']._serialized_end=319
|
||||
_globals['_POINT2D']._serialized_start=321
|
||||
_globals['_POINT2D']._serialized_end=352
|
||||
_globals['_POINT']._serialized_start=354
|
||||
_globals['_POINT']._serialized_end=394
|
||||
_globals['_SIZE2DI']._serialized_start=396
|
||||
_globals['_SIZE2DI']._serialized_end=427
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
55
worlds/_sc2common/bot/proto/data_pb2.py
Normal file
55
worlds/_sc2common/bot/proto/data_pb2.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/data.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/data.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as s2clientprotocol_dot_common__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bs2clientprotocol/data.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xc4\x03\n\x0b\x41\x62ilityData\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x11\n\tlink_name\x18\x02 \x01(\t\x12\x12\n\nlink_index\x18\x03 \x01(\r\x12\x13\n\x0b\x62utton_name\x18\x04 \x01(\t\x12\x15\n\rfriendly_name\x18\x05 \x01(\t\x12\x0e\n\x06hotkey\x18\x06 \x01(\t\x12\x1c\n\x14remaps_to_ability_id\x18\x07 \x01(\r\x12\x11\n\tavailable\x18\x08 \x01(\x08\x12\x32\n\x06target\x18\t \x01(\x0e\x32\".SC2APIProtocol.AbilityData.Target\x12\x15\n\rallow_minimap\x18\n \x01(\x08\x12\x16\n\x0e\x61llow_autocast\x18\x0b \x01(\x08\x12\x13\n\x0bis_building\x18\x0c \x01(\x08\x12\x18\n\x10\x66ootprint_radius\x18\r \x01(\x02\x12\x1c\n\x14is_instant_placement\x18\x0e \x01(\x08\x12\x12\n\ncast_range\x18\x0f \x01(\x02\"I\n\x06Target\x12\x08\n\x04None\x10\x01\x12\t\n\x05Point\x10\x02\x12\x08\n\x04Unit\x10\x03\x12\x0f\n\x0bPointOrUnit\x10\x04\x12\x0f\n\x0bPointOrNone\x10\x05\"J\n\x0b\x44\x61mageBonus\x12,\n\tattribute\x18\x01 \x01(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\r\n\x05\x62onus\x18\x02 \x01(\x02\"\xd7\x01\n\x06Weapon\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.SC2APIProtocol.Weapon.TargetType\x12\x0e\n\x06\x64\x61mage\x18\x02 \x01(\x02\x12\x31\n\x0c\x64\x61mage_bonus\x18\x03 \x03(\x0b\x32\x1b.SC2APIProtocol.DamageBonus\x12\x0f\n\x07\x61ttacks\x18\x04 \x01(\r\x12\r\n\x05range\x18\x05 \x01(\x02\x12\r\n\x05speed\x18\x06 \x01(\x02\"*\n\nTargetType\x12\n\n\x06Ground\x10\x01\x12\x07\n\x03\x41ir\x10\x02\x12\x07\n\x03\x41ny\x10\x03\"\x95\x04\n\x0cUnitTypeData\x12\x0f\n\x07unit_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x11\n\tavailable\x18\x03 \x01(\x08\x12\x12\n\ncargo_size\x18\x04 \x01(\r\x12\x14\n\x0cmineral_cost\x18\x0c \x01(\r\x12\x14\n\x0cvespene_cost\x18\r \x01(\r\x12\x15\n\rfood_required\x18\x0e \x01(\x02\x12\x15\n\rfood_provided\x18\x12 \x01(\x02\x12\x12\n\nability_id\x18\x0f \x01(\r\x12\"\n\x04race\x18\x10 \x01(\x0e\x32\x14.SC2APIProtocol.Race\x12\x12\n\nbuild_time\x18\x11 \x01(\x02\x12\x13\n\x0bhas_vespene\x18\x13 \x01(\x08\x12\x14\n\x0chas_minerals\x18\x14 \x01(\x08\x12\x13\n\x0bsight_range\x18\x19 \x01(\x02\x12\x12\n\ntech_alias\x18\x15 \x03(\r\x12\x12\n\nunit_alias\x18\x16 \x01(\r\x12\x18\n\x10tech_requirement\x18\x17 \x01(\r\x12\x18\n\x10require_attached\x18\x18 \x01(\x08\x12-\n\nattributes\x18\x08 \x03(\x0e\x32\x19.SC2APIProtocol.Attribute\x12\x16\n\x0emovement_speed\x18\t \x01(\x02\x12\r\n\x05\x61rmor\x18\n \x01(\x02\x12\'\n\x07weapons\x18\x0b \x03(\x0b\x32\x16.SC2APIProtocol.Weapon\"\x86\x01\n\x0bUpgradeData\x12\x12\n\nupgrade_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x14\n\x0cmineral_cost\x18\x03 \x01(\r\x12\x14\n\x0cvespene_cost\x18\x04 \x01(\r\x12\x15\n\rresearch_time\x18\x05 \x01(\x02\x12\x12\n\nability_id\x18\x06 \x01(\r\")\n\x08\x42uffData\x12\x0f\n\x07\x62uff_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\"T\n\nEffectData\x12\x11\n\teffect_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x15\n\rfriendly_name\x18\x03 \x01(\t\x12\x0e\n\x06radius\x18\x04 \x01(\x02*\x9e\x01\n\tAttribute\x12\t\n\x05Light\x10\x01\x12\x0b\n\x07\x41rmored\x10\x02\x12\x0e\n\nBiological\x10\x03\x12\x0e\n\nMechanical\x10\x04\x12\x0b\n\x07Robotic\x10\x05\x12\x0b\n\x07Psionic\x10\x06\x12\x0b\n\x07Massive\x10\x07\x12\r\n\tStructure\x10\x08\x12\t\n\x05Hover\x10\t\x12\n\n\x06Heroic\x10\n\x12\x0c\n\x08Summoned\x10\x0b')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.data_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_ATTRIBUTE']._serialized_start=1630
|
||||
_globals['_ATTRIBUTE']._serialized_end=1788
|
||||
_globals['_ABILITYDATA']._serialized_start=79
|
||||
_globals['_ABILITYDATA']._serialized_end=531
|
||||
_globals['_ABILITYDATA_TARGET']._serialized_start=458
|
||||
_globals['_ABILITYDATA_TARGET']._serialized_end=531
|
||||
_globals['_DAMAGEBONUS']._serialized_start=533
|
||||
_globals['_DAMAGEBONUS']._serialized_end=607
|
||||
_globals['_WEAPON']._serialized_start=610
|
||||
_globals['_WEAPON']._serialized_end=825
|
||||
_globals['_WEAPON_TARGETTYPE']._serialized_start=783
|
||||
_globals['_WEAPON_TARGETTYPE']._serialized_end=825
|
||||
_globals['_UNITTYPEDATA']._serialized_start=828
|
||||
_globals['_UNITTYPEDATA']._serialized_end=1361
|
||||
_globals['_UPGRADEDATA']._serialized_start=1364
|
||||
_globals['_UPGRADEDATA']._serialized_end=1498
|
||||
_globals['_BUFFDATA']._serialized_start=1500
|
||||
_globals['_BUFFDATA']._serialized_end=1541
|
||||
_globals['_EFFECTDATA']._serialized_start=1543
|
||||
_globals['_EFFECTDATA']._serialized_end=1627
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
71
worlds/_sc2common/bot/proto/debug_pb2.py
Normal file
71
worlds/_sc2common/bot/proto/debug_pb2.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/debug.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/debug.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as s2clientprotocol_dot_common__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/debug.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\xbb\x03\n\x0c\x44\x65\x62ugCommand\x12)\n\x04\x64raw\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.DebugDrawH\x00\x12\x34\n\ngame_state\x18\x02 \x01(\x0e\x32\x1e.SC2APIProtocol.DebugGameStateH\x00\x12\x36\n\x0b\x63reate_unit\x18\x03 \x01(\x0b\x32\x1f.SC2APIProtocol.DebugCreateUnitH\x00\x12\x32\n\tkill_unit\x18\x04 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugKillUnitH\x00\x12\x38\n\x0ctest_process\x18\x05 \x01(\x0b\x32 .SC2APIProtocol.DebugTestProcessH\x00\x12.\n\x05score\x18\x06 \x01(\x0b\x32\x1d.SC2APIProtocol.DebugSetScoreH\x00\x12\x30\n\x08\x65nd_game\x18\x07 \x01(\x0b\x32\x1c.SC2APIProtocol.DebugEndGameH\x00\x12\x37\n\nunit_value\x18\x08 \x01(\x0b\x32!.SC2APIProtocol.DebugSetUnitValueH\x00\x42\t\n\x07\x63ommand\"\xb5\x01\n\tDebugDraw\x12\'\n\x04text\x18\x01 \x03(\x0b\x32\x19.SC2APIProtocol.DebugText\x12(\n\x05lines\x18\x02 \x03(\x0b\x32\x19.SC2APIProtocol.DebugLine\x12\'\n\x05\x62oxes\x18\x03 \x03(\x0b\x32\x18.SC2APIProtocol.DebugBox\x12,\n\x07spheres\x18\x04 \x03(\x0b\x32\x1b.SC2APIProtocol.DebugSphere\"L\n\x04Line\x12!\n\x02p0\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12!\n\x02p1\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"(\n\x05\x43olor\x12\t\n\x01r\x18\x01 \x01(\r\x12\t\n\x01g\x18\x02 \x01(\r\x12\t\n\x01\x62\x18\x03 \x01(\r\"\xa3\x01\n\tDebugText\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\x0c\n\x04text\x18\x02 \x01(\t\x12*\n\x0bvirtual_pos\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12(\n\tworld_pos\x18\x04 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\x0c\n\x04size\x18\x05 \x01(\r\"U\n\tDebugLine\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x04line\x18\x02 \x01(\x0b\x32\x14.SC2APIProtocol.Line\"x\n\x08\x44\x65\x62ugBox\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12\"\n\x03min\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\"\n\x03max\x18\x03 \x01(\x0b\x32\x15.SC2APIProtocol.Point\"`\n\x0b\x44\x65\x62ugSphere\x12$\n\x05\x63olor\x18\x01 \x01(\x0b\x32\x15.SC2APIProtocol.Color\x12 \n\x01p\x18\x02 \x01(\x0b\x32\x15.SC2APIProtocol.Point\x12\t\n\x01r\x18\x03 \x01(\x02\"k\n\x0f\x44\x65\x62ugCreateUnit\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\r\n\x05owner\x18\x02 \x01(\x05\x12$\n\x03pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x10\n\x08quantity\x18\x04 \x01(\r\"\x1c\n\rDebugKillUnit\x12\x0b\n\x03tag\x18\x01 \x03(\x04\"\x80\x01\n\x10\x44\x65\x62ugTestProcess\x12\x33\n\x04test\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.DebugTestProcess.Test\x12\x10\n\x08\x64\x65lay_ms\x18\x02 \x01(\x05\"%\n\x04Test\x12\x08\n\x04hang\x10\x01\x12\t\n\x05\x63rash\x10\x02\x12\x08\n\x04\x65xit\x10\x03\"\x1e\n\rDebugSetScore\x12\r\n\x05score\x18\x01 \x01(\x02\"z\n\x0c\x44\x65\x62ugEndGame\x12:\n\nend_result\x18\x01 \x01(\x0e\x32&.SC2APIProtocol.DebugEndGame.EndResult\".\n\tEndResult\x12\r\n\tSurrender\x10\x01\x12\x12\n\x0e\x44\x65\x63lareVictory\x10\x02\"\xa5\x01\n\x11\x44\x65\x62ugSetUnitValue\x12?\n\nunit_value\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.DebugSetUnitValue.UnitValue\x12\r\n\x05value\x18\x02 \x01(\x02\x12\x10\n\x08unit_tag\x18\x03 \x01(\x04\".\n\tUnitValue\x12\n\n\x06\x45nergy\x10\x01\x12\x08\n\x04Life\x10\x02\x12\x0b\n\x07Shields\x10\x03*\xb2\x01\n\x0e\x44\x65\x62ugGameState\x12\x0c\n\x08show_map\x10\x01\x12\x11\n\rcontrol_enemy\x10\x02\x12\x08\n\x04\x66ood\x10\x03\x12\x08\n\x04\x66ree\x10\x04\x12\x11\n\rall_resources\x10\x05\x12\x07\n\x03god\x10\x06\x12\x0c\n\x08minerals\x10\x07\x12\x07\n\x03gas\x10\x08\x12\x0c\n\x08\x63ooldown\x10\t\x12\r\n\ttech_tree\x10\n\x12\x0b\n\x07upgrade\x10\x0b\x12\x0e\n\nfast_build\x10\x0c')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.debug_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_DEBUGGAMESTATE']._serialized_start=1897
|
||||
_globals['_DEBUGGAMESTATE']._serialized_end=2075
|
||||
_globals['_DEBUGCOMMAND']._serialized_start=80
|
||||
_globals['_DEBUGCOMMAND']._serialized_end=523
|
||||
_globals['_DEBUGDRAW']._serialized_start=526
|
||||
_globals['_DEBUGDRAW']._serialized_end=707
|
||||
_globals['_LINE']._serialized_start=709
|
||||
_globals['_LINE']._serialized_end=785
|
||||
_globals['_COLOR']._serialized_start=787
|
||||
_globals['_COLOR']._serialized_end=827
|
||||
_globals['_DEBUGTEXT']._serialized_start=830
|
||||
_globals['_DEBUGTEXT']._serialized_end=993
|
||||
_globals['_DEBUGLINE']._serialized_start=995
|
||||
_globals['_DEBUGLINE']._serialized_end=1080
|
||||
_globals['_DEBUGBOX']._serialized_start=1082
|
||||
_globals['_DEBUGBOX']._serialized_end=1202
|
||||
_globals['_DEBUGSPHERE']._serialized_start=1204
|
||||
_globals['_DEBUGSPHERE']._serialized_end=1300
|
||||
_globals['_DEBUGCREATEUNIT']._serialized_start=1302
|
||||
_globals['_DEBUGCREATEUNIT']._serialized_end=1409
|
||||
_globals['_DEBUGKILLUNIT']._serialized_start=1411
|
||||
_globals['_DEBUGKILLUNIT']._serialized_end=1439
|
||||
_globals['_DEBUGTESTPROCESS']._serialized_start=1442
|
||||
_globals['_DEBUGTESTPROCESS']._serialized_end=1570
|
||||
_globals['_DEBUGTESTPROCESS_TEST']._serialized_start=1533
|
||||
_globals['_DEBUGTESTPROCESS_TEST']._serialized_end=1570
|
||||
_globals['_DEBUGSETSCORE']._serialized_start=1572
|
||||
_globals['_DEBUGSETSCORE']._serialized_end=1602
|
||||
_globals['_DEBUGENDGAME']._serialized_start=1604
|
||||
_globals['_DEBUGENDGAME']._serialized_end=1726
|
||||
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_start=1680
|
||||
_globals['_DEBUGENDGAME_ENDRESULT']._serialized_end=1726
|
||||
_globals['_DEBUGSETUNITVALUE']._serialized_start=1729
|
||||
_globals['_DEBUGSETUNITVALUE']._serialized_end=1894
|
||||
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_start=1848
|
||||
_globals['_DEBUGSETUNITVALUE_UNITVALUE']._serialized_end=1894
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
36
worlds/_sc2common/bot/proto/error_pb2.py
Normal file
36
worlds/_sc2common/bot/proto/error_pb2.py
Normal file
File diff suppressed because one or more lines are too long
52
worlds/_sc2common/bot/proto/query_pb2.py
Normal file
52
worlds/_sc2common/bot/proto/query_pb2.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/query.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/query.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as s2clientprotocol_dot_common__pb2
|
||||
from . import error_pb2 as s2clientprotocol_dot_error__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/query.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\x1a\x1cs2clientprotocol/error.proto\"\xf0\x01\n\x0cRequestQuery\x12\x34\n\x07pathing\x18\x01 \x03(\x0b\x32#.SC2APIProtocol.RequestQueryPathing\x12\x41\n\tabilities\x18\x02 \x03(\x0b\x32..SC2APIProtocol.RequestQueryAvailableAbilities\x12\x41\n\nplacements\x18\x03 \x03(\x0b\x32-.SC2APIProtocol.RequestQueryBuildingPlacement\x12$\n\x1cignore_resource_requirements\x18\x04 \x01(\x08\"\xce\x01\n\rResponseQuery\x12\x35\n\x07pathing\x18\x01 \x03(\x0b\x32$.SC2APIProtocol.ResponseQueryPathing\x12\x42\n\tabilities\x18\x02 \x03(\x0b\x32/.SC2APIProtocol.ResponseQueryAvailableAbilities\x12\x42\n\nplacements\x18\x03 \x03(\x0b\x32..SC2APIProtocol.ResponseQueryBuildingPlacement\"\x8a\x01\n\x13RequestQueryPathing\x12,\n\tstart_pos\x18\x01 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DH\x00\x12\x12\n\x08unit_tag\x18\x02 \x01(\x04H\x00\x12(\n\x07\x65nd_pos\x18\x03 \x01(\x0b\x32\x17.SC2APIProtocol.Point2DB\x07\n\x05start\"(\n\x14ResponseQueryPathing\x12\x10\n\x08\x64istance\x18\x01 \x01(\x02\"2\n\x1eRequestQueryAvailableAbilities\x12\x10\n\x08unit_tag\x18\x01 \x01(\x04\"~\n\x1fResponseQueryAvailableAbilities\x12\x33\n\tabilities\x18\x01 \x03(\x0b\x32 .SC2APIProtocol.AvailableAbility\x12\x10\n\x08unit_tag\x18\x02 \x01(\x04\x12\x14\n\x0cunit_type_id\x18\x03 \x01(\r\"z\n\x1dRequestQueryBuildingPlacement\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12+\n\ntarget_pos\x18\x02 \x01(\x0b\x32\x17.SC2APIProtocol.Point2D\x12\x18\n\x10placing_unit_tag\x18\x03 \x01(\x04\"N\n\x1eResponseQueryBuildingPlacement\x12,\n\x06result\x18\x01 \x01(\x0e\x32\x1c.SC2APIProtocol.ActionResult')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.query_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_REQUESTQUERY']._serialized_start=110
|
||||
_globals['_REQUESTQUERY']._serialized_end=350
|
||||
_globals['_RESPONSEQUERY']._serialized_start=353
|
||||
_globals['_RESPONSEQUERY']._serialized_end=559
|
||||
_globals['_REQUESTQUERYPATHING']._serialized_start=562
|
||||
_globals['_REQUESTQUERYPATHING']._serialized_end=700
|
||||
_globals['_RESPONSEQUERYPATHING']._serialized_start=702
|
||||
_globals['_RESPONSEQUERYPATHING']._serialized_end=742
|
||||
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_start=744
|
||||
_globals['_REQUESTQUERYAVAILABLEABILITIES']._serialized_end=794
|
||||
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_start=796
|
||||
_globals['_RESPONSEQUERYAVAILABLEABILITIES']._serialized_end=922
|
||||
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_start=924
|
||||
_globals['_REQUESTQUERYBUILDINGPLACEMENT']._serialized_end=1046
|
||||
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_start=1048
|
||||
_globals['_RESPONSEQUERYBUILDINGPLACEMENT']._serialized_end=1126
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
73
worlds/_sc2common/bot/proto/raw_pb2.py
Normal file
73
worlds/_sc2common/bot/proto/raw_pb2.py
Normal file
File diff suppressed because one or more lines are too long
197
worlds/_sc2common/bot/proto/sc2api_pb2.py
Normal file
197
worlds/_sc2common/bot/proto/sc2api_pb2.py
Normal file
File diff suppressed because one or more lines are too long
44
worlds/_sc2common/bot/proto/score_pb2.py
Normal file
44
worlds/_sc2common/bot/proto/score_pb2.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/score.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/score.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cs2clientprotocol/score.proto\x12\x0eSC2APIProtocol\"\xa8\x01\n\x05Score\x12\x33\n\nscore_type\x18\x06 \x01(\x0e\x32\x1f.SC2APIProtocol.Score.ScoreType\x12\r\n\x05score\x18\x07 \x01(\x05\x12\x33\n\rscore_details\x18\x08 \x01(\x0b\x32\x1c.SC2APIProtocol.ScoreDetails\"&\n\tScoreType\x12\x0e\n\nCurriculum\x10\x01\x12\t\n\x05Melee\x10\x02\"h\n\x14\x43\x61tegoryScoreDetails\x12\x0c\n\x04none\x18\x01 \x01(\x02\x12\x0c\n\x04\x61rmy\x18\x02 \x01(\x02\x12\x0f\n\x07\x65\x63onomy\x18\x03 \x01(\x02\x12\x12\n\ntechnology\x18\x04 \x01(\x02\x12\x0f\n\x07upgrade\x18\x05 \x01(\x02\"B\n\x11VitalScoreDetails\x12\x0c\n\x04life\x18\x01 \x01(\x02\x12\x0f\n\x07shields\x18\x02 \x01(\x02\x12\x0e\n\x06\x65nergy\x18\x03 \x01(\x02\"\x8a\n\n\x0cScoreDetails\x12\x1c\n\x14idle_production_time\x18\x01 \x01(\x02\x12\x18\n\x10idle_worker_time\x18\x02 \x01(\x02\x12\x19\n\x11total_value_units\x18\x03 \x01(\x02\x12\x1e\n\x16total_value_structures\x18\x04 \x01(\x02\x12\x1a\n\x12killed_value_units\x18\x05 \x01(\x02\x12\x1f\n\x17killed_value_structures\x18\x06 \x01(\x02\x12\x1a\n\x12\x63ollected_minerals\x18\x07 \x01(\x02\x12\x19\n\x11\x63ollected_vespene\x18\x08 \x01(\x02\x12 \n\x18\x63ollection_rate_minerals\x18\t \x01(\x02\x12\x1f\n\x17\x63ollection_rate_vespene\x18\n \x01(\x02\x12\x16\n\x0espent_minerals\x18\x0b \x01(\x02\x12\x15\n\rspent_vespene\x18\x0c \x01(\x02\x12\x37\n\tfood_used\x18\r \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x0fkilled_minerals\x18\x0e \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12<\n\x0ekilled_vespene\x18\x0f \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rlost_minerals\x18\x10 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0clost_vespene\x18\x11 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x44\n\x16\x66riendly_fire_minerals\x18\x12 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x43\n\x15\x66riendly_fire_vespene\x18\x13 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12;\n\rused_minerals\x18\x14 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12:\n\x0cused_vespene\x18\x15 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12\x41\n\x13total_used_minerals\x18\x16 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12@\n\x12total_used_vespene\x18\x17 \x01(\x0b\x32$.SC2APIProtocol.CategoryScoreDetails\x12=\n\x12total_damage_dealt\x18\x18 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12=\n\x12total_damage_taken\x18\x19 \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x37\n\x0ctotal_healed\x18\x1a \x01(\x0b\x32!.SC2APIProtocol.VitalScoreDetails\x12\x13\n\x0b\x63urrent_apm\x18\x1b \x01(\x02\x12\x1d\n\x15\x63urrent_effective_apm\x18\x1c \x01(\x02')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.score_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_SCORE']._serialized_start=49
|
||||
_globals['_SCORE']._serialized_end=217
|
||||
_globals['_SCORE_SCORETYPE']._serialized_start=179
|
||||
_globals['_SCORE_SCORETYPE']._serialized_end=217
|
||||
_globals['_CATEGORYSCOREDETAILS']._serialized_start=219
|
||||
_globals['_CATEGORYSCOREDETAILS']._serialized_end=323
|
||||
_globals['_VITALSCOREDETAILS']._serialized_start=325
|
||||
_globals['_VITALSCOREDETAILS']._serialized_end=391
|
||||
_globals['_SCOREDETAILS']._serialized_start=394
|
||||
_globals['_SCOREDETAILS']._serialized_end=1684
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
55
worlds/_sc2common/bot/proto/spatial_pb2.py
Normal file
55
worlds/_sc2common/bot/proto/spatial_pb2.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/spatial.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/spatial.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as s2clientprotocol_dot_common__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1es2clientprotocol/spatial.proto\x12\x0eSC2APIProtocol\x1a\x1ds2clientprotocol/common.proto\"\x88\x01\n\x17ObservationFeatureLayer\x12.\n\x07renders\x18\x01 \x01(\x0b\x32\x1d.SC2APIProtocol.FeatureLayers\x12=\n\x0fminimap_renders\x18\x02 \x01(\x0b\x32$.SC2APIProtocol.FeatureLayersMinimap\"\x9c\n\n\rFeatureLayers\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05power\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_hit_points\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x38\n\x15unit_hit_points_ratio\x18\x11 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bunit_energy\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x34\n\x11unit_energy_ratio\x18\x12 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_shields\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x35\n\x12unit_shields_ratio\x18\x13 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0funit_density_aa\x18\x0e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12/\n\x0cunit_density\x18\x0f \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x65\x66\x66\x65\x63ts\x18\x14 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0ehallucinations\x18\x15 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07\x63loaked\x18\x16 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\'\n\x04\x62lip\x18\x17 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x62uffs\x18\x18 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x30\n\rbuff_duration\x18\x1a \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61\x63tive\x18\x19 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0e\x62uild_progress\x18\x1b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\x1c \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x1d \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12.\n\x0bplaceholder\x18\x1e \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\x90\x04\n\x14\x46\x65\x61tureLayersMinimap\x12-\n\nheight_map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x31\n\x0evisibility_map\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12(\n\x05\x63reep\x18\x03 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x63\x61mera\x18\x04 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tplayer_id\x18\x05 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12\x32\n\x0fplayer_relative\x18\x06 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08selected\x18\x07 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12)\n\x06\x61lerts\x18\t \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tbuildable\x18\n \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12+\n\x08pathable\x18\x0b \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12,\n\tunit_type\x18\x08 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"g\n\x11ObservationRender\x12&\n\x03map\x18\x01 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\x12*\n\x07minimap\x18\x02 \x01(\x0b\x32\x19.SC2APIProtocol.ImageData\"\xbb\x02\n\rActionSpatial\x12@\n\x0cunit_command\x18\x01 \x01(\x0b\x32(.SC2APIProtocol.ActionSpatialUnitCommandH\x00\x12>\n\x0b\x63\x61mera_move\x18\x02 \x01(\x0b\x32\'.SC2APIProtocol.ActionSpatialCameraMoveH\x00\x12O\n\x14unit_selection_point\x18\x03 \x01(\x0b\x32/.SC2APIProtocol.ActionSpatialUnitSelectionPointH\x00\x12M\n\x13unit_selection_rect\x18\x04 \x01(\x0b\x32..SC2APIProtocol.ActionSpatialUnitSelectionRectH\x00\x42\x08\n\x06\x61\x63tion\"\xbe\x01\n\x18\x41\x63tionSpatialUnitCommand\x12\x12\n\nability_id\x18\x01 \x01(\x05\x12\x35\n\x13target_screen_coord\x18\x02 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x36\n\x14target_minimap_coord\x18\x03 \x01(\x0b\x32\x16.SC2APIProtocol.PointIH\x00\x12\x15\n\rqueue_command\x18\x04 \x01(\x08\x42\x08\n\x06target\"I\n\x17\x41\x63tionSpatialCameraMove\x12.\n\x0e\x63\x65nter_minimap\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\"\xda\x01\n\x1f\x41\x63tionSpatialUnitSelectionPoint\x12\x36\n\x16selection_screen_coord\x18\x01 \x01(\x0b\x32\x16.SC2APIProtocol.PointI\x12\x42\n\x04type\x18\x02 \x01(\x0e\x32\x34.SC2APIProtocol.ActionSpatialUnitSelectionPoint.Type\";\n\x04Type\x12\n\n\x06Select\x10\x01\x12\n\n\x06Toggle\x10\x02\x12\x0b\n\x07\x41llType\x10\x03\x12\x0e\n\nAddAllType\x10\x04\"s\n\x1e\x41\x63tionSpatialUnitSelectionRect\x12:\n\x16selection_screen_coord\x18\x01 \x03(\x0b\x32\x1a.SC2APIProtocol.RectangleI\x12\x15\n\rselection_add\x18\x02 \x01(\x08')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.spatial_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_OBSERVATIONFEATURELAYER']._serialized_start=82
|
||||
_globals['_OBSERVATIONFEATURELAYER']._serialized_end=218
|
||||
_globals['_FEATURELAYERS']._serialized_start=221
|
||||
_globals['_FEATURELAYERS']._serialized_end=1529
|
||||
_globals['_FEATURELAYERSMINIMAP']._serialized_start=1532
|
||||
_globals['_FEATURELAYERSMINIMAP']._serialized_end=2060
|
||||
_globals['_OBSERVATIONRENDER']._serialized_start=2062
|
||||
_globals['_OBSERVATIONRENDER']._serialized_end=2165
|
||||
_globals['_ACTIONSPATIAL']._serialized_start=2168
|
||||
_globals['_ACTIONSPATIAL']._serialized_end=2483
|
||||
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_start=2486
|
||||
_globals['_ACTIONSPATIALUNITCOMMAND']._serialized_end=2676
|
||||
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_start=2678
|
||||
_globals['_ACTIONSPATIALCAMERAMOVE']._serialized_end=2751
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_start=2754
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONPOINT']._serialized_end=2972
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_start=2913
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONPOINT_TYPE']._serialized_end=2972
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_start=2974
|
||||
_globals['_ACTIONSPATIALUNITSELECTIONRECT']._serialized_end=3089
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
76
worlds/_sc2common/bot/proto/ui_pb2.py
Normal file
76
worlds/_sc2common/bot/proto/ui_pb2.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: s2clientprotocol/ui.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
's2clientprotocol/ui.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19s2clientprotocol/ui.proto\x12\x0eSC2APIProtocol\"\x86\x02\n\rObservationUI\x12,\n\x06groups\x18\x01 \x03(\x0b\x32\x1c.SC2APIProtocol.ControlGroup\x12-\n\x06single\x18\x02 \x01(\x0b\x32\x1b.SC2APIProtocol.SinglePanelH\x00\x12+\n\x05multi\x18\x03 \x01(\x0b\x32\x1a.SC2APIProtocol.MultiPanelH\x00\x12+\n\x05\x63\x61rgo\x18\x04 \x01(\x0b\x32\x1a.SC2APIProtocol.CargoPanelH\x00\x12\x35\n\nproduction\x18\x05 \x01(\x0b\x32\x1f.SC2APIProtocol.ProductionPanelH\x00\x42\x07\n\x05panel\"T\n\x0c\x43ontrolGroup\x12\x1b\n\x13\x63ontrol_group_index\x18\x01 \x01(\r\x12\x18\n\x10leader_unit_type\x18\x02 \x01(\r\x12\r\n\x05\x63ount\x18\x03 \x01(\r\"\x85\x02\n\x08UnitInfo\x12\x11\n\tunit_type\x18\x01 \x01(\r\x12\x17\n\x0fplayer_relative\x18\x02 \x01(\r\x12\x0e\n\x06health\x18\x03 \x01(\x05\x12\x0f\n\x07shields\x18\x04 \x01(\x05\x12\x0e\n\x06\x65nergy\x18\x05 \x01(\x05\x12\x1d\n\x15transport_slots_taken\x18\x06 \x01(\x05\x12\x16\n\x0e\x62uild_progress\x18\x07 \x01(\x02\x12(\n\x06\x61\x64\x64_on\x18\x08 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x12\n\nmax_health\x18\t \x01(\x05\x12\x13\n\x0bmax_shields\x18\n \x01(\x05\x12\x12\n\nmax_energy\x18\x0b \x01(\x05\"\x9d\x01\n\x0bSinglePanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x1c\n\x14\x61ttack_upgrade_level\x18\x02 \x01(\x05\x12\x1b\n\x13\x61rmor_upgrade_level\x18\x03 \x01(\x05\x12\x1c\n\x14shield_upgrade_level\x18\x04 \x01(\x05\x12\r\n\x05\x62uffs\x18\x05 \x03(\x05\"5\n\nMultiPanel\x12\'\n\x05units\x18\x01 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\"{\n\nCargoPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12,\n\npassengers\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x17\n\x0fslots_available\x18\x03 \x01(\x05\"7\n\tBuildItem\x12\x12\n\nability_id\x18\x01 \x01(\r\x12\x16\n\x0e\x62uild_progress\x18\x02 \x01(\x02\"\x9d\x01\n\x0fProductionPanel\x12&\n\x04unit\x18\x01 \x01(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12-\n\x0b\x62uild_queue\x18\x02 \x03(\x0b\x32\x18.SC2APIProtocol.UnitInfo\x12\x33\n\x10production_queue\x18\x03 \x03(\x0b\x32\x19.SC2APIProtocol.BuildItem\"\xda\x04\n\x08\x41\x63tionUI\x12;\n\rcontrol_group\x18\x01 \x01(\x0b\x32\".SC2APIProtocol.ActionControlGroupH\x00\x12\x37\n\x0bselect_army\x18\x02 \x01(\x0b\x32 .SC2APIProtocol.ActionSelectArmyH\x00\x12\x42\n\x11select_warp_gates\x18\x03 \x01(\x0b\x32%.SC2APIProtocol.ActionSelectWarpGatesH\x00\x12\x39\n\x0cselect_larva\x18\x04 \x01(\x0b\x32!.SC2APIProtocol.ActionSelectLarvaH\x00\x12\x44\n\x12select_idle_worker\x18\x05 \x01(\x0b\x32&.SC2APIProtocol.ActionSelectIdleWorkerH\x00\x12\x37\n\x0bmulti_panel\x18\x06 \x01(\x0b\x32 .SC2APIProtocol.ActionMultiPanelH\x00\x12=\n\x0b\x63\x61rgo_panel\x18\x07 \x01(\x0b\x32&.SC2APIProtocol.ActionCargoPanelUnloadH\x00\x12P\n\x10production_panel\x18\x08 \x01(\x0b\x32\x34.SC2APIProtocol.ActionProductionPanelRemoveFromQueueH\x00\x12?\n\x0ftoggle_autocast\x18\t \x01(\x0b\x32$.SC2APIProtocol.ActionToggleAutocastH\x00\x42\x08\n\x06\x61\x63tion\"\xd4\x01\n\x12\x41\x63tionControlGroup\x12\x45\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x35.SC2APIProtocol.ActionControlGroup.ControlGroupAction\x12\x1b\n\x13\x63ontrol_group_index\x18\x02 \x01(\r\"Z\n\x12\x43ontrolGroupAction\x12\n\n\x06Recall\x10\x01\x12\x07\n\x03Set\x10\x02\x12\n\n\x06\x41ppend\x10\x03\x12\x0f\n\x0bSetAndSteal\x10\x04\x12\x12\n\x0e\x41ppendAndSteal\x10\x05\")\n\x10\x41\x63tionSelectArmy\x12\x15\n\rselection_add\x18\x01 \x01(\x08\".\n\x15\x41\x63tionSelectWarpGates\x12\x15\n\rselection_add\x18\x01 \x01(\x08\"\x13\n\x11\x41\x63tionSelectLarva\"\x82\x01\n\x16\x41\x63tionSelectIdleWorker\x12\x39\n\x04type\x18\x01 \x01(\x0e\x32+.SC2APIProtocol.ActionSelectIdleWorker.Type\"-\n\x04Type\x12\x07\n\x03Set\x10\x01\x12\x07\n\x03\x41\x64\x64\x10\x02\x12\x07\n\x03\x41ll\x10\x03\x12\n\n\x06\x41\x64\x64\x41ll\x10\x04\"\xb3\x01\n\x10\x41\x63tionMultiPanel\x12\x33\n\x04type\x18\x01 \x01(\x0e\x32%.SC2APIProtocol.ActionMultiPanel.Type\x12\x12\n\nunit_index\x18\x02 \x01(\x05\"V\n\x04Type\x12\x10\n\x0cSingleSelect\x10\x01\x12\x10\n\x0c\x44\x65selectUnit\x10\x02\x12\x13\n\x0fSelectAllOfType\x10\x03\x12\x15\n\x11\x44\x65selectAllOfType\x10\x04\",\n\x16\x41\x63tionCargoPanelUnload\x12\x12\n\nunit_index\x18\x01 \x01(\x05\":\n$ActionProductionPanelRemoveFromQueue\x12\x12\n\nunit_index\x18\x01 \x01(\x05\"*\n\x14\x41\x63tionToggleAutocast\x12\x12\n\nability_id\x18\x01 \x01(\x05')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 's2clientprotocol.ui_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_OBSERVATIONUI']._serialized_start=46
|
||||
_globals['_OBSERVATIONUI']._serialized_end=308
|
||||
_globals['_CONTROLGROUP']._serialized_start=310
|
||||
_globals['_CONTROLGROUP']._serialized_end=394
|
||||
_globals['_UNITINFO']._serialized_start=397
|
||||
_globals['_UNITINFO']._serialized_end=658
|
||||
_globals['_SINGLEPANEL']._serialized_start=661
|
||||
_globals['_SINGLEPANEL']._serialized_end=818
|
||||
_globals['_MULTIPANEL']._serialized_start=820
|
||||
_globals['_MULTIPANEL']._serialized_end=873
|
||||
_globals['_CARGOPANEL']._serialized_start=875
|
||||
_globals['_CARGOPANEL']._serialized_end=998
|
||||
_globals['_BUILDITEM']._serialized_start=1000
|
||||
_globals['_BUILDITEM']._serialized_end=1055
|
||||
_globals['_PRODUCTIONPANEL']._serialized_start=1058
|
||||
_globals['_PRODUCTIONPANEL']._serialized_end=1215
|
||||
_globals['_ACTIONUI']._serialized_start=1218
|
||||
_globals['_ACTIONUI']._serialized_end=1820
|
||||
_globals['_ACTIONCONTROLGROUP']._serialized_start=1823
|
||||
_globals['_ACTIONCONTROLGROUP']._serialized_end=2035
|
||||
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_start=1945
|
||||
_globals['_ACTIONCONTROLGROUP_CONTROLGROUPACTION']._serialized_end=2035
|
||||
_globals['_ACTIONSELECTARMY']._serialized_start=2037
|
||||
_globals['_ACTIONSELECTARMY']._serialized_end=2078
|
||||
_globals['_ACTIONSELECTWARPGATES']._serialized_start=2080
|
||||
_globals['_ACTIONSELECTWARPGATES']._serialized_end=2126
|
||||
_globals['_ACTIONSELECTLARVA']._serialized_start=2128
|
||||
_globals['_ACTIONSELECTLARVA']._serialized_end=2147
|
||||
_globals['_ACTIONSELECTIDLEWORKER']._serialized_start=2150
|
||||
_globals['_ACTIONSELECTIDLEWORKER']._serialized_end=2280
|
||||
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_start=2235
|
||||
_globals['_ACTIONSELECTIDLEWORKER_TYPE']._serialized_end=2280
|
||||
_globals['_ACTIONMULTIPANEL']._serialized_start=2283
|
||||
_globals['_ACTIONMULTIPANEL']._serialized_end=2462
|
||||
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_start=2376
|
||||
_globals['_ACTIONMULTIPANEL_TYPE']._serialized_end=2462
|
||||
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_start=2464
|
||||
_globals['_ACTIONCARGOPANELUNLOAD']._serialized_end=2508
|
||||
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_start=2510
|
||||
_globals['_ACTIONPRODUCTIONPANELREMOVEFROMQUEUE']._serialized_end=2568
|
||||
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_start=2570
|
||||
_globals['_ACTIONTOGGLEAUTOCAST']._serialized_end=2612
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -4,7 +4,7 @@ from contextlib import suppress
|
||||
|
||||
from aiohttp import ClientWebSocketResponse
|
||||
from worlds._sc2common.bot import logger
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
from .data import Status
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import traceback
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
from worlds._sc2common.bot import logger
|
||||
from s2clientprotocol import sc2api_pb2 as sc_pb
|
||||
from .proto import sc2api_pb2 as sc_pb
|
||||
|
||||
from .controller import Controller
|
||||
from .data import Result, Status
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
|
||||
from s2clientprotocol import score_pb2 as score_pb
|
||||
from .proto import score_pb2 as score_pb
|
||||
|
||||
from .position import Point2
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
s2clientprotocol>=5.0.11.90136.0
|
||||
mpyq>=0.2.5
|
||||
portpicker>=1.5.2
|
||||
aiohttp>=3.8.4
|
||||
loguru>=0.7.0
|
||||
protobuf==3.20.3
|
||||
protobuf==6.33.5
|
||||
|
||||
@@ -8,7 +8,7 @@ import bsdiff4
|
||||
|
||||
import Utils
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from worlds.Files import APPatch
|
||||
from .Locations import LocationData
|
||||
|
||||
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
||||
@@ -78,7 +78,7 @@ class BatNoTouchLocation:
|
||||
return ret_dict
|
||||
|
||||
|
||||
class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
|
||||
class AdventureDeltaPatch(APPatch):
|
||||
hash = ADVENTUREHASH
|
||||
game = "Adventure"
|
||||
patch_file_ending = ".apadvn"
|
||||
|
||||
@@ -63,6 +63,9 @@ def is_location_valid(world: "HatInTimeWorld", location: str) -> bool:
|
||||
if not world.options.ShuffleStorybookPages and location in storybook_pages.keys():
|
||||
return False
|
||||
|
||||
if not world.options.ShuffleDirectorTokens and location in director_tokens.keys():
|
||||
return False
|
||||
|
||||
if not world.options.ShuffleActContracts and location in contract_locations.keys():
|
||||
return False
|
||||
|
||||
@@ -566,6 +569,34 @@ storybook_pages = {
|
||||
"Rumbi Factory - Page: Last Area": LocData(2000345883, "Time Rift - Rumbi Factory", dlc_flags=HatDLC.dlc2),
|
||||
}
|
||||
|
||||
director_tokens = {
|
||||
"Murder on the Owl Express - Conductor Token: Cafeteria": LocData(2001104767, "Murder on the Owl Express"),
|
||||
"Murder on the Owl Express - Conductor Token: Recreational Room": LocData(2001104768, "Murder on the Owl Express"),
|
||||
"Picture Perfect - DJ Grooves Token: Cardboard Puppy": LocData(2001203990, "Picture Perfect"),
|
||||
"Picture Perfect - DJ Grooves Token: Card Guessing Game": LocData(2001203991, "Picture Perfect"),
|
||||
"Picture Perfect - DJ Grooves Token: Back Alley": LocData(2001203992, "Picture Perfect"),
|
||||
"Picture Perfect - DJ Grooves Token: Cooking Show": LocData(2001203993, "Picture Perfect"),
|
||||
"Picture Perfect - DJ Grooves Token: Pon Cluster": LocData(2001203987, "Picture Perfect"),
|
||||
"Train Rush - Time Bonus: 1st Room": LocData(2001305235, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Falling Platform": LocData(2001305189, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Acid Crates": LocData(2001305186, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Balloon": LocData(2001305239, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Ring of Fire": LocData(2001305237, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Blue Panels": LocData(2001305236, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Sinking Lava Platform": LocData(2001305234, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Lava Panels 1": LocData(2001305193, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Lava Panels 2": LocData(2001305190, "Train Rush", hookshot=True),
|
||||
"Train Rush - Time Bonus: Lava Panels 3": LocData(2001305238, "Train Rush", hookshot=True),
|
||||
"The Big Parade - DJ Grooves Token (1/8)": LocData(2001400000, "The Big Parade"),
|
||||
"The Big Parade - DJ Grooves Token (2/8)": LocData(2001400001, "The Big Parade"),
|
||||
"The Big Parade - DJ Grooves Token (3/8)": LocData(2001400002, "The Big Parade"),
|
||||
"The Big Parade - DJ Grooves Token (4/8)": LocData(2001400003, "The Big Parade"),
|
||||
"The Big Parade - DJ Grooves Token (5/8)": LocData(2001400004, "The Big Parade", hit_type=HitType.umbrella),
|
||||
"The Big Parade - DJ Grooves Token (6/8)": LocData(2001400005, "The Big Parade", hit_type=HitType.umbrella),
|
||||
"The Big Parade - DJ Grooves Token (7/8)": LocData(2001400006, "The Big Parade", hit_type=HitType.umbrella),
|
||||
"The Big Parade - DJ Grooves Token (8/8)": LocData(2001400007, "The Big Parade", hit_type=HitType.umbrella),
|
||||
}
|
||||
|
||||
shop_locations = {
|
||||
"Badge Seller - Item 1": LocData(2000301003, "Badge Seller"),
|
||||
"Badge Seller - Item 2": LocData(2000301004, "Badge Seller"),
|
||||
@@ -1050,6 +1081,7 @@ location_table = {
|
||||
**ahit_locations,
|
||||
**act_completions,
|
||||
**storybook_pages,
|
||||
**director_tokens,
|
||||
**contract_locations,
|
||||
**shop_locations,
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ def adjust_options(world: "HatInTimeWorld"):
|
||||
world.options.EndGoal.value = EndGoal.option_seal_the_deal
|
||||
world.options.ActRandomizer.value = 0
|
||||
world.options.ShuffleAlpineZiplines.value = 0
|
||||
world.options.ShuffleDirectorTokens.value = 0
|
||||
world.options.ShuffleSubconPaintings.value = 0
|
||||
world.options.ShuffleStorybookPages.value = 0
|
||||
world.options.ShuffleActContracts.value = 0
|
||||
@@ -219,6 +220,12 @@ class ShuffleStorybookPages(DefaultOnToggle):
|
||||
display_name = "Shuffle Storybook Pages"
|
||||
|
||||
|
||||
class ShuffleDirectorTokens(Toggle):
|
||||
"""If enabled, causes the Conductor/DJ Grooves tokens found in Chapter 2 levels to become item checks.
|
||||
NOTE: This also includes the time bonus pickups from Train Rush, since the level doesn't have any tokens."""
|
||||
display_name = "Shuffle Director Tokens"
|
||||
|
||||
|
||||
class ShuffleActContracts(DefaultOnToggle):
|
||||
"""If enabled, shuffle Snatcher's act contracts into the pool as items"""
|
||||
display_name = "Shuffle Contracts"
|
||||
@@ -658,6 +665,7 @@ class AHITOptions(PerGameCommonOptions):
|
||||
StartWithCompassBadge: StartWithCompassBadge
|
||||
CompassBadgeMode: CompassBadgeMode
|
||||
ShuffleStorybookPages: ShuffleStorybookPages
|
||||
ShuffleDirectorTokens: ShuffleDirectorTokens
|
||||
ShuffleActContracts: ShuffleActContracts
|
||||
ShuffleSubconPaintings: ShuffleSubconPaintings
|
||||
NoPaintingSkips: NoPaintingSkips
|
||||
@@ -722,7 +730,8 @@ class AHITOptions(PerGameCommonOptions):
|
||||
|
||||
|
||||
ahit_option_groups: Dict[str, List[Any]] = {
|
||||
"General Options": [EndGoal, ShuffleStorybookPages, ShuffleAlpineZiplines, ShuffleSubconPaintings,
|
||||
"General Options": [EndGoal, ShuffleStorybookPages, ShuffleDirectorTokens,
|
||||
ShuffleAlpineZiplines, ShuffleSubconPaintings,
|
||||
ShuffleActContracts, MinPonCost, MaxPonCost, BadgeSellerMinItems, BadgeSellerMaxItems,
|
||||
LogicDifficulty, NoPaintingSkips, CTRLogic],
|
||||
|
||||
@@ -759,6 +768,7 @@ slot_data_options: List[str] = [
|
||||
"StartWithCompassBadge",
|
||||
"CompassBadgeMode",
|
||||
"ShuffleStorybookPages",
|
||||
"ShuffleDirectorTokens",
|
||||
"ShuffleActContracts",
|
||||
"ShuffleSubconPaintings",
|
||||
"NoPaintingSkips",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user