mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-25 11:13:21 -07:00
Compare commits
139 Commits
0.6.3-rc1
...
webhost_qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1169e62191 | ||
|
|
d0bd1d29b1 | ||
|
|
b162095f89 | ||
|
|
33b485c0c3 | ||
|
|
4893ac3e51 | ||
|
|
76b0197462 | ||
|
|
6a63de2f0f | ||
|
|
e6fb7d9c6a | ||
|
|
0882c0fa97 | ||
|
|
f26fcc0eda | ||
|
|
50c9d056c9 | ||
|
|
5cec3f45f5 | ||
|
|
448f214cdb | ||
|
|
49f2d30587 | ||
|
|
897d5ab089 | ||
|
|
92ff0ddba8 | ||
|
|
1d2ad1f9c9 | ||
|
|
516ebc53ce | ||
|
|
a30b43821f | ||
|
|
d9955d624b | ||
|
|
5345937966 | ||
|
|
580370c3a0 | ||
|
|
c30a5b206e | ||
|
|
053f876e84 | ||
|
|
ab2097960d | ||
|
|
2f23dc72f9 | ||
|
|
f9083d9307 | ||
|
|
25baa57850 | ||
|
|
47b2242c3c | ||
|
|
6099869c59 | ||
|
|
1d861d1d06 | ||
|
|
d1624679ee | ||
|
|
12998bf6f4 | ||
|
|
24394561bd | ||
|
|
4ae87edf37 | ||
|
|
4525bae879 | ||
|
|
dc270303a9 | ||
|
|
a99da85a22 | ||
|
|
e256abfdfb | ||
|
|
fb9011da63 | ||
|
|
68187ba25f | ||
|
|
6c45c8d606 | ||
|
|
9e96cece56 | ||
|
|
1bd44e1e35 | ||
|
|
7badc3e745 | ||
|
|
3af1e92813 | ||
|
|
73718bbd61 | ||
|
|
8f2b4a961f | ||
|
|
9fdeecd996 | ||
|
|
174d89c81f | ||
|
|
71de33d7dd | ||
|
|
9c00eb91d6 | ||
|
|
597583577a | ||
|
|
4e085894d2 | ||
|
|
76a8b0d582 | ||
|
|
27e50aa81a | ||
|
|
aaaceebd91 | ||
|
|
1322ce866e | ||
|
|
78b529fc23 | ||
|
|
9aa0bf7245 | ||
|
|
287bb638a0 | ||
|
|
18ac9210cb | ||
|
|
17dad8313e | ||
|
|
63f3512829 | ||
|
|
1b200fb20b | ||
|
|
8a091c9e02 | ||
|
|
c3c517a200 | ||
|
|
c5b404baa8 | ||
|
|
77cab13827 | ||
|
|
31b2eed1f9 | ||
|
|
e23720a977 | ||
|
|
90058ee175 | ||
|
|
5c6dbdd98f | ||
|
|
8c2d246a53 | ||
|
|
0d26b6426f | ||
|
|
b9fb5c8b44 | ||
|
|
e518e41f67 | ||
|
|
7a38e44e64 | ||
|
|
64d3c55d62 | ||
|
|
89be26a33a | ||
|
|
5b5e2c3567 | ||
|
|
ef59a5ee11 | ||
|
|
b0b3e3668f | ||
|
|
42ace29db4 | ||
|
|
03992c43d9 | ||
|
|
e342a20fde | ||
|
|
3c28db0800 | ||
|
|
8f88152532 | ||
|
|
7a1311984f | ||
|
|
a9f594d6b2 | ||
|
|
5f1835c546 | ||
|
|
2359cceb64 | ||
|
|
a0a1c5d4c0 | ||
|
|
14d65fdf28 | ||
|
|
5fd9570368 | ||
|
|
c753fbff2d | ||
|
|
cdf7165ab4 | ||
|
|
893acd2f02 | ||
|
|
34aaa44b1f | ||
|
|
f2461a2fea | ||
|
|
bb2ecb8a97 | ||
|
|
439be48f36 | ||
|
|
750c8a9810 | ||
|
|
e11b40c94b | ||
|
|
be51fb9ba9 | ||
|
|
e1fca86cf8 | ||
|
|
1fa342b085 | ||
|
|
d146d90131 | ||
|
|
d5bdac02b7 | ||
|
|
dfd7cbf0c5 | ||
|
|
88a4a589a0 | ||
|
|
bead81b64b | ||
|
|
16d5b453a7 | ||
|
|
48906de873 | ||
|
|
9a64b8c5ce | ||
|
|
6ba2b7f8c3 | ||
|
|
6f7ca082f2 | ||
|
|
eb09be3594 | ||
|
|
9d654b7e3b | ||
|
|
8f7fcd4889 | ||
|
|
b85887241f | ||
|
|
5110676c76 | ||
|
|
0020e6c3d3 | ||
|
|
6e6fd0e9bc | ||
|
|
85c26f9740 | ||
|
|
9057ce0ce3 | ||
|
|
378cc91a4d | ||
|
|
cdde38fdc9 | ||
|
|
c34c00baa4 | ||
|
|
9bd535752e | ||
|
|
ecb22642af | ||
|
|
17ccfdc266 | ||
|
|
4633f12972 | ||
|
|
1f6c99635e | ||
|
|
4e92cac171 | ||
|
|
3b88630b0d | ||
|
|
e6d2d8f455 | ||
|
|
84c2d70d9a | ||
|
|
d463faa9d9 |
2
.github/pyright-config.json
vendored
2
.github/pyright-config.json
vendored
@@ -29,7 +29,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.10",
|
"pythonVersion": "3.11",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
2
.github/workflows/analyze-modified-files.yml
vendored
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
|
|||||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -22,9 +22,9 @@ env:
|
|||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
# 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.
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
APPIMAGETOOL_VERSION: continuous
|
APPIMAGETOOL_VERSION: continuous
|
||||||
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
APPIMAGE_RUNTIME_VERSION: continuous
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
permissions: # permissions required for attestation
|
permissions: # permissions required for attestation
|
||||||
id-token: 'write'
|
id-token: 'write'
|
||||||
|
|||||||
154
.github/workflows/docker.yml
vendored
Normal file
154
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
name: Build and Publish Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "**"
|
||||||
|
- "!docs/**"
|
||||||
|
- "!deploy/**"
|
||||||
|
- "!setup.py"
|
||||||
|
- "!.gitignore"
|
||||||
|
- "!.github/workflows/**"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
tags:
|
||||||
|
- "v?[0-9]+.[0-9]+.[0-9]*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image-name: ${{ steps.image.outputs.name }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
package-name: ${{ steps.package.outputs.name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set lowercase image name
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set package name
|
||||||
|
id: package
|
||||||
|
run: |
|
||||||
|
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable={{is_not_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=nightly,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Compute final tags
|
||||||
|
id: final-tags
|
||||||
|
run: |
|
||||||
|
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
tag="${{ github.ref_name }}"
|
||||||
|
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
|
||||||
|
# Check if latest is already in tags to avoid duplicates
|
||||||
|
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
|
||||||
|
tags+=("$full_latest")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set multiline output
|
||||||
|
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
suffix: amd64
|
||||||
|
cache-scope: amd64
|
||||||
|
- platform: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
suffix: arm64
|
||||||
|
cache-scope: arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Compute suffixed tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
|
||||||
|
suffixed=()
|
||||||
|
for t in "${tags[@]}"; do
|
||||||
|
suffixed+=("$t-${{ matrix.suffix }}")
|
||||||
|
done
|
||||||
|
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/${{ matrix.platform }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.tags.outputs.tags }}
|
||||||
|
labels: ${{ needs.prepare.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||||
|
provenance: false
|
||||||
|
|
||||||
|
manifest:
|
||||||
|
needs: [prepare, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push multi-arch manifest
|
||||||
|
run: |
|
||||||
|
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
|
||||||
|
|
||||||
|
for tag in "${tag_array[@]}"; do
|
||||||
|
docker manifest create "$tag" \
|
||||||
|
"$tag-amd64" \
|
||||||
|
"$tag-arm64"
|
||||||
|
|
||||||
|
docker manifest push "$tag"
|
||||||
|
done
|
||||||
1
.github/workflows/label-pull-requests.yml
vendored
1
.github/workflows/label-pull-requests.yml
vendored
@@ -12,7 +12,6 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
name: 'Apply content-based labels'
|
name: 'Apply content-based labels'
|
||||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v5
|
- uses: actions/labeler@v5
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -5,16 +5,16 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
# 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.
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
APPIMAGETOOL_VERSION: continuous
|
APPIMAGETOOL_VERSION: continuous
|
||||||
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
APPIMAGE_RUNTIME_VERSION: continuous
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
permissions: # permissions required for attestation
|
permissions: # permissions required for attestation
|
||||||
id-token: 'write'
|
id-token: 'write'
|
||||||
|
|||||||
12
.github/workflows/unittests.yml
vendored
12
.github/workflows/unittests.yml
vendored
@@ -39,15 +39,15 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
python:
|
python:
|
||||||
- {version: '3.10'}
|
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||||
- {version: '3.11'}
|
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
|
- {version: '3.13'}
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.10'} # old compat
|
- python: {version: '3.11'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.12'} # current
|
- {version: '3.13'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ class MultiWorld():
|
|||||||
"local_items": set(item_link.get("local_items", [])),
|
"local_items": set(item_link.get("local_items", [])),
|
||||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
|
"skip_if_solo": item_link.get("skip_if_solo", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _name, item_link in item_links.items():
|
for _name, item_link in item_links.items():
|
||||||
@@ -284,6 +285,8 @@ class MultiWorld():
|
|||||||
|
|
||||||
for group_name, item_link in item_links.items():
|
for group_name, item_link in item_links.items():
|
||||||
game = item_link["game"]
|
game = item_link["game"]
|
||||||
|
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
|
||||||
|
continue
|
||||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||||
|
|
||||||
group["item_pool"] = item_link["item_pool"]
|
group["item_pool"] = item_link["item_pool"]
|
||||||
@@ -1571,7 +1574,7 @@ class ItemClassification(IntFlag):
|
|||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
"""As Network API flag int."""
|
"""As Network API flag int."""
|
||||||
return int(self & 0b0111)
|
return int(self & 0b00111)
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
@@ -1899,7 +1902,8 @@ class Spoiler:
|
|||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
|
||||||
|
for unreachable in sorted(self.unreachables)]))
|
||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
|
|||||||
@@ -99,17 +99,6 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_current_datapackage(self) -> dict[str, typing.Any]:
|
|
||||||
"""
|
|
||||||
Return datapackage for current game if known.
|
|
||||||
|
|
||||||
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
|
|
||||||
"""
|
|
||||||
if not self.ctx.game:
|
|
||||||
return {}
|
|
||||||
checksum = self.ctx.checksums[self.ctx.game]
|
|
||||||
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
|
|
||||||
|
|
||||||
def _cmd_missing(self, filter_text = "") -> bool:
|
def _cmd_missing(self, filter_text = "") -> bool:
|
||||||
"""List all missing location checks, from your local game state.
|
"""List all missing location checks, from your local game state.
|
||||||
Can be given text, which will be used as filter."""
|
Can be given text, which will be used as filter."""
|
||||||
@@ -119,8 +108,8 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
|
|
||||||
lookup = self.get_current_datapackage().get("location_name_to_id", {})
|
lookup = self.ctx.location_names[self.ctx.game]
|
||||||
for location, location_id in lookup.items():
|
for location_id, location in lookup.items():
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -141,11 +130,10 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def output_datapackage_part(self, key: str, name: str) -> bool:
|
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
||||||
"""
|
"""
|
||||||
Helper to digest a specific section of this game's datapackage.
|
Helper to digest a specific section of this game's datapackage.
|
||||||
|
|
||||||
:param key: The dictionary key in the datapackage.
|
|
||||||
:param name: Printed to the user as context for the part.
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
:return: Whether the process was successful.
|
:return: Whether the process was successful.
|
||||||
@@ -154,23 +142,20 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output(f"No game set, cannot determine {name}.")
|
self.output(f"No game set, cannot determine {name}.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
lookup = self.get_current_datapackage().get(key)
|
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
||||||
if lookup is None:
|
lookup = lookup[self.ctx.game]
|
||||||
self.output("datapackage not yet loaded, try again")
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.output(f"{name} for {self.ctx.game}")
|
self.output(f"{name} for {self.ctx.game}")
|
||||||
for key in lookup:
|
for name in lookup.values():
|
||||||
self.output(key)
|
self.output(name)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_items(self) -> bool:
|
def _cmd_items(self) -> bool:
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
return self.output_datapackage_part("item_name_to_id", "Item Names")
|
return self.output_datapackage_part("Item Names")
|
||||||
|
|
||||||
def _cmd_locations(self) -> bool:
|
def _cmd_locations(self) -> bool:
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
return self.output_datapackage_part("location_name_to_id", "Location Names")
|
return self.output_datapackage_part("Location Names")
|
||||||
|
|
||||||
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||||
filter_key: str,
|
filter_key: str,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ COPY requirements.txt WebHostLib/requirements.txt
|
|||||||
|
|
||||||
RUN pip install --no-cache-dir -r \
|
RUN pip install --no-cache-dir -r \
|
||||||
WebHostLib/requirements.txt \
|
WebHostLib/requirements.txt \
|
||||||
setuptools
|
"setuptools>=75,<81"
|
||||||
|
|
||||||
COPY _speedups.pyx .
|
COPY _speedups.pyx .
|
||||||
COPY intset.h .
|
COPY intset.h .
|
||||||
@@ -36,7 +36,7 @@ COPY intset.h .
|
|||||||
RUN cythonize -b -i _speedups.pyx
|
RUN cythonize -b -i _speedups.pyx
|
||||||
|
|
||||||
# Archipelago
|
# Archipelago
|
||||||
FROM python:3.12-slim AS archipelago
|
FROM python:3.12-slim-bookworm AS archipelago
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|||||||
4
Fill.py
4
Fill.py
@@ -549,10 +549,12 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if prioritylocations and regular_progression:
|
if prioritylocations and regular_progression:
|
||||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
||||||
|
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
||||||
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority Retry", one_item_per_player=False, allow_partial=True)
|
name="Priority Retry", one_item_per_player=False,
|
||||||
|
allow_partial=bool(deprioritized_progression))
|
||||||
|
|
||||||
if prioritylocations and deprioritized_progression:
|
if prioritylocations and deprioritized_progression:
|
||||||
# There are no more regular progression items that can be placed on any priority locations.
|
# There are no more regular progression items that can be placed on any priority locations.
|
||||||
|
|||||||
54
Generate.py
54
Generate.py
@@ -166,19 +166,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
args.outputname = seed_name
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.seed = seed
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.plando_options = args.plando
|
args.name = {}
|
||||||
erargs.spoiler = args.spoiler
|
|
||||||
erargs.race = args.race
|
|
||||||
erargs.outputname = seed_name
|
|
||||||
erargs.outputpath = args.outputpath
|
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
|
||||||
erargs.skip_output = args.skip_output
|
|
||||||
erargs.spoiler_only = args.spoiler_only
|
|
||||||
erargs.name = {}
|
|
||||||
erargs.csv_output = args.csv_output
|
|
||||||
|
|
||||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
@@ -205,7 +196,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
erargs.player_options = {}
|
args.player_options = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
@@ -218,21 +209,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
try:
|
try:
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||||
|
|
||||||
# name was not specified
|
# name was not specified
|
||||||
if player not in erargs.name:
|
if player not in args.name:
|
||||||
if path == args.weights_file_path:
|
if path == args.weights_file_path:
|
||||||
# weights file, so we need to make the name unique
|
# weights file, so we need to make the name unique
|
||||||
erargs.name[player] = f"Player{player}"
|
args.name[player] = f"Player{player}"
|
||||||
else:
|
else:
|
||||||
# use the filename
|
# use the filename
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -240,10 +231,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
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 erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
|
||||||
|
|
||||||
return erargs, seed
|
return args, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||||
@@ -495,7 +486,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
f"which is not enabled.")
|
f"which is not enabled.")
|
||||||
|
games = requirements.get("game", {})
|
||||||
|
for game, version in games.items():
|
||||||
|
if game not in AutoWorldRegister.world_types:
|
||||||
|
continue
|
||||||
|
if not version:
|
||||||
|
raise Exception(f"Invalid version for game {game}: {version}.")
|
||||||
|
if isinstance(version, str):
|
||||||
|
version = {"min": version}
|
||||||
|
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
|
||||||
|
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
|
||||||
|
f"however world is of version "
|
||||||
|
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||||
|
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
|
||||||
|
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
|
||||||
|
f"however world is of version "
|
||||||
|
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||||
ret = argparse.Namespace()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
if __name__ == '__main__':
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
|
||||||
|
|
||||||
from worlds.kh1.Client import launch
|
|
||||||
launch()
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import ModuleUpdate
|
|
||||||
import Utils
|
|
||||||
from worlds.kh2.Client import launch
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
|
||||||
launch()
|
|
||||||
@@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
Utils.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Archipelago Launcher',
|
description='Archipelago Launcher',
|
||||||
|
|||||||
@@ -412,10 +412,10 @@ class LinksAwakeningClient():
|
|||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||||
|
|
||||||
item_id -= LABaseID
|
item_id -= LABaseID
|
||||||
# The player name table only goes up to 100, so don't go past that
|
# The player name table only goes up to 101, so don't go past that
|
||||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||||
if from_player > 100:
|
if from_player > 101:
|
||||||
from_player = 100
|
from_player = 101
|
||||||
|
|
||||||
next_index += 1
|
next_index += 1
|
||||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||||
|
|||||||
6
Main.py
6
Main.py
@@ -37,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
multiworld.plando_options = args.plando
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -59,7 +59,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
logger.info(f" {name:{longest_name}}: "
|
||||||
|
f"v{cls.world_version.as_simple_string()} |"
|
||||||
|
f"Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"Locations: {len(cls.location_names):{location_count}}")
|
f"Locations: {len(cls.location_names):{location_count}}")
|
||||||
|
|
||||||
del item_count, location_count
|
del item_count, location_count
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
|
||||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
|
||||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
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.
|
# 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.")
|
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||||
elif sys.version_info < (3, 10, 1):
|
elif sys.version_info < (3, 11, 0):
|
||||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(
|
_skip_update = bool(
|
||||||
@@ -74,11 +74,11 @@ def update_command():
|
|||||||
def install_pkg_resources(yes=False):
|
def install_pkg_resources(yes=False):
|
||||||
try:
|
try:
|
||||||
import pkg_resources # noqa: F401
|
import pkg_resources # noqa: F401
|
||||||
except ImportError:
|
except (AttributeError, ImportError):
|
||||||
check_pip()
|
check_pip()
|
||||||
if not yes:
|
if not yes:
|
||||||
confirm("pkg_resources not found, press enter to install it")
|
confirm("pkg_resources not found, press enter to install it")
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
def update(yes: bool = False, force: bool = False) -> None:
|
||||||
|
|||||||
129
MultiServer.py
129
MultiServer.py
@@ -1135,8 +1135,13 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
|
||||||
-> typing.List[Hint]:
|
status: HintStatus | None = None) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given item id or name, with a given status.
|
||||||
|
If status is None (which is the default value), an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1152,25 +1157,38 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
else:
|
else:
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
new_status = auto_status
|
|
||||||
if found:
|
if found:
|
||||||
new_status = HintStatus.HINT_FOUND
|
status = HintStatus.HINT_FOUND
|
||||||
elif item_flags & ItemClassification.trap:
|
elif status is None:
|
||||||
new_status = HintStatus.HINT_AVOID
|
if item_flags & ItemClassification.trap:
|
||||||
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
status = HintStatus.HINT_AVOID
|
||||||
item_flags, new_status))
|
else:
|
||||||
|
status = HintStatus.HINT_PRIORITY
|
||||||
|
|
||||||
|
hints.append(
|
||||||
|
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
|
||||||
|
)
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
|
||||||
-> typing.List[Hint]:
|
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
|
||||||
|
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
|
||||||
-> typing.List[Hint]:
|
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
|
||||||
|
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||||
if prev_hint:
|
if prev_hint:
|
||||||
return [prev_hint]
|
return [prev_hint]
|
||||||
@@ -1180,13 +1198,16 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
|||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
new_status = auto_status
|
|
||||||
if found:
|
if found:
|
||||||
new_status = HintStatus.HINT_FOUND
|
status = HintStatus.HINT_FOUND
|
||||||
elif item_flags & ItemClassification.trap:
|
elif status is None:
|
||||||
new_status = HintStatus.HINT_AVOID
|
if item_flags & ItemClassification.trap:
|
||||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
status = HintStatus.HINT_AVOID
|
||||||
new_status)]
|
else:
|
||||||
|
status = HintStatus.HINT_PRIORITY
|
||||||
|
|
||||||
|
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -1300,7 +1321,8 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
argname += "=" + parameter.default
|
argname += "=" + parameter.default
|
||||||
argtext += argname
|
argtext += argname
|
||||||
argtext += " "
|
argtext += " "
|
||||||
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
|
||||||
|
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def _cmd_help(self):
|
def _cmd_help(self):
|
||||||
@@ -1329,19 +1351,6 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
class CommonCommandProcessor(CommandProcessor):
|
class CommonCommandProcessor(CommandProcessor):
|
||||||
ctx: Context
|
ctx: Context
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
"""List all current options. Warning: lists password."""
|
"""List all current options. Warning: lists password."""
|
||||||
self.output("Current options:")
|
self.output("Current options:")
|
||||||
@@ -1610,7 +1619,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1636,9 +1644,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif not for_location:
|
elif not for_location:
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
@@ -1658,16 +1666,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
hints.extend(
|
||||||
|
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
||||||
|
)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1945,8 +1955,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
|
|
||||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
@@ -1961,6 +1970,16 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
if not locations:
|
if not locations:
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = HintStatus(status)
|
||||||
|
except ValueError as err:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": f"Unknown Status: {err}",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
|
|
||||||
@@ -2228,6 +2247,19 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_release(self, player_name: str) -> bool:
|
def _cmd_release(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
@@ -2349,9 +2381,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||||
else: # item name or id
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
hints = collect_hints(self.ctx, team, slot, item)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
@@ -2385,17 +2417,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
if isinstance(location, int):
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||||
HintStatus.HINT_UNSPECIFIED)
|
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||||
HintStatus.HINT_UNSPECIFIED)
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1446,6 +1446,7 @@ class ItemLinks(OptionList):
|
|||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)],
|
Optional("non_local_items"): [And(str, len)],
|
||||||
Optional("link_replacement"): Or(None, bool),
|
Optional("link_replacement"): Or(None, bool),
|
||||||
|
Optional("skip_if_solo"): Or(None, bool),
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1709,7 +1710,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
from Utils import local_path, __version__
|
from Utils import local_path, __version__, tuplize_version
|
||||||
|
|
||||||
full_path: str
|
full_path: str
|
||||||
|
|
||||||
@@ -1752,7 +1753,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
res = template.render(
|
res = template.render(
|
||||||
option_groups=option_groups,
|
option_groups=option_groups,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__,
|
||||||
|
game=game_name,
|
||||||
|
world_version=world.world_version.as_simple_string(),
|
||||||
|
yaml_dump=yaml_dump_scalar,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
cleandoc=cleandoc,
|
cleandoc=cleandoc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ Currently, the following games are supported:
|
|||||||
* Meritous
|
* Meritous
|
||||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||||
* ChecksFinder
|
* ChecksFinder
|
||||||
* ArchipIDLE
|
|
||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
@@ -81,6 +80,8 @@ Currently, the following games are supported:
|
|||||||
* Super Mario Land 2: 6 Golden Coins
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
* shapez
|
* shapez
|
||||||
* Paint
|
* Paint
|
||||||
|
* Celeste (Open World)
|
||||||
|
* Choo-Choo Charles
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
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
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.sc2.Client import launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
|
||||||
launch()
|
|
||||||
38
Utils.py
38
Utils.py
@@ -47,8 +47,9 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.3"
|
__version__ = "0.6.4"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
version = Version(*version_tuple)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
is_macos = sys.platform == "darwin"
|
is_macos = sys.platform == "darwin"
|
||||||
@@ -322,11 +323,13 @@ def get_options() -> Settings:
|
|||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: str, value: typing.Any):
|
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
||||||
path = user_path("_persistent_storage.yaml")
|
|
||||||
storage = persistent_load()
|
storage = persistent_load()
|
||||||
|
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
|
||||||
|
return # no changes necessary
|
||||||
category_dict = storage.setdefault(category, {})
|
category_dict = storage.setdefault(category, {})
|
||||||
category_dict[key] = value
|
category_dict[key] = value
|
||||||
|
path = user_path("_persistent_storage.yaml")
|
||||||
with open(path, "wt") as f:
|
with open(path, "wt") as f:
|
||||||
f.write(dump(storage, Dumper=Dumper))
|
f.write(dump(storage, Dumper=Dumper))
|
||||||
|
|
||||||
@@ -414,11 +417,11 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
|||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
common_path = cache_path("common.json")
|
common_path = cache_path("common.json")
|
||||||
if os.path.exists(common_path):
|
try:
|
||||||
with open(common_path) as f:
|
with open(common_path) as f:
|
||||||
common_file = json.load(f)
|
common_file = json.load(f)
|
||||||
uuid = common_file.get("uuid", None)
|
uuid = common_file.get("uuid", None)
|
||||||
else:
|
except FileNotFoundError:
|
||||||
common_file = {}
|
common_file = {}
|
||||||
uuid = None
|
uuid = None
|
||||||
|
|
||||||
@@ -428,6 +431,9 @@ def get_unique_identifier():
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
uuid = str(uuid4())
|
uuid = str(uuid4())
|
||||||
common_file["uuid"] = uuid
|
common_file["uuid"] = uuid
|
||||||
|
|
||||||
|
cache_folder = os.path.dirname(common_path)
|
||||||
|
os.makedirs(cache_folder, exist_ok=True)
|
||||||
with open(common_path, "w") as f:
|
with open(common_path, "w") as f:
|
||||||
json.dump(common_file, f, separators=(",", ":"))
|
json.dump(common_file, f, separators=(",", ":"))
|
||||||
return uuid
|
return uuid
|
||||||
@@ -900,7 +906,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||||
to prevent early garbage collection. "fire-and-forget"
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
"""
|
"""
|
||||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
|
||||||
# Python docs:
|
# Python docs:
|
||||||
# ```
|
# ```
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
@@ -937,15 +943,15 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
||||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
# original upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import multiprocessing.spawn
|
import multiprocessing.spawn
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
def _freeze_support() -> None:
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
from subprocess import _args_from_interpreter_flags
|
from subprocess import _args_from_interpreter_flags # noqa
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
@@ -972,17 +978,23 @@ def _extend_freeze_support() -> None:
|
|||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
if not is_windows and is_frozen():
|
def _noop() -> None:
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
pass
|
||||||
|
|
||||||
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
||||||
|
|
||||||
|
|
||||||
def freeze_support() -> None:
|
def freeze_support() -> None:
|
||||||
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
_extend_freeze_support()
|
|
||||||
|
deprecate("Use multiprocessing.freeze_support() instead")
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
|
||||||
|
_extend_freeze_support()
|
||||||
|
|
||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
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:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
|||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
|
# trigger endpoint registration
|
||||||
from . import datapackage, generate, room, user # trigger registration
|
from . import datapackage, generate, room, tracker, user
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
|
import typing
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
from pony.orm import commit, select
|
||||||
|
|
||||||
from Utils import restricted_dumps
|
from Utils import restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app, cache
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
from WebHostLib.models import Generation, STATE_QUEUED, STATE_STARTED, Seed, STATE_ERROR
|
||||||
from . import api_endpoints
|
from . import api_endpoints
|
||||||
|
|
||||||
|
|
||||||
@@ -74,12 +75,23 @@ def generate_api():
|
|||||||
def wait_seed_api(seed: UUID):
|
def wait_seed_api(seed: UUID):
|
||||||
seed_id = seed
|
seed_id = seed
|
||||||
seed = Seed.get(id=seed_id)
|
seed = Seed.get(id=seed_id)
|
||||||
|
reply_dict: dict[str, typing.Any] = {"queue_len": get_queue_length()}
|
||||||
if seed:
|
if seed:
|
||||||
return {"text": "Generation done"}, 201
|
reply_dict["text"] = "Generation done"
|
||||||
|
return reply_dict, 201
|
||||||
generation = Generation.get(id=seed_id)
|
generation = Generation.get(id=seed_id)
|
||||||
|
|
||||||
if not generation:
|
if not generation:
|
||||||
return {"text": "Generation not found"}, 404
|
reply_dict["text"] = "Generation not found"
|
||||||
|
return reply_dict, 404
|
||||||
elif generation.state == STATE_ERROR:
|
elif generation.state == STATE_ERROR:
|
||||||
return {"text": "Generation failed"}, 500
|
reply_dict["text"] = "Generation failed"
|
||||||
return {"text": "Generation running"}, 202
|
return reply_dict, 500
|
||||||
|
reply_dict["text"] = "Generation running"
|
||||||
|
return reply_dict, 202
|
||||||
|
|
||||||
|
|
||||||
|
@cache.memoize(timeout=5)
|
||||||
|
def get_queue_length() -> int:
|
||||||
|
return select(generation for generation in Generation if
|
||||||
|
generation.state == STATE_STARTED or generation.state == STATE_QUEUED).count()
|
||||||
|
|||||||
241
WebHostLib/api/tracker.py
Normal file
241
WebHostLib/api/tracker.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
|
||||||
|
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
|
||||||
|
from WebHostLib import cache
|
||||||
|
from WebHostLib.api import api_endpoints
|
||||||
|
from WebHostLib.models import Room
|
||||||
|
from WebHostLib.tracker import TrackerData
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerAlias(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
alias: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerItemsReceived(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
items: list[NetworkItem]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerChecksDone(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
locations: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class TeamTotalChecks(TypedDict):
|
||||||
|
team: int
|
||||||
|
checks_done: int
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerHints(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
hints: list[Hint]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerTimer(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
time: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatus(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerLocationsTotal(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
total_locations: int
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route("/tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
player_aliases: list[PlayerAlias] = []
|
||||||
|
"""Slot aliases of all players."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||||
|
|
||||||
|
player_items_received: list[PlayerItemsReceived] = []
|
||||||
|
"""Items received by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_items_received.append(
|
||||||
|
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||||
|
|
||||||
|
player_checks_done: list[PlayerChecksDone] = []
|
||||||
|
"""ID of all locations checked by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_checks_done.append(
|
||||||
|
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||||
|
|
||||||
|
total_checks_done: list[TeamTotalChecks] = [
|
||||||
|
{"team": team, "checks_done": checks_done}
|
||||||
|
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
|
||||||
|
]
|
||||||
|
"""Total number of locations checked for the entire multiworld per team."""
|
||||||
|
|
||||||
|
hints: list[PlayerHints] = []
|
||||||
|
"""Hints that all players have used or received."""
|
||||||
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
for player in players:
|
||||||
|
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
||||||
|
hints.append({"team": team, "player": player, "hints": player_hints})
|
||||||
|
slot_info = tracker_data.get_slot_info(player)
|
||||||
|
# this assumes groups are always after players
|
||||||
|
if slot_info.type != SlotType.group:
|
||||||
|
continue
|
||||||
|
for member in slot_info.group_members:
|
||||||
|
hints[member - 1]["hints"] += player_hints
|
||||||
|
|
||||||
|
activity_timers: list[PlayerTimer] = []
|
||||||
|
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
activity_timers.append({"team": team, "player": player, "time": None})
|
||||||
|
|
||||||
|
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
|
||||||
|
for entry in activity_timers:
|
||||||
|
if entry["team"] == team and entry["player"] == player:
|
||||||
|
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
break
|
||||||
|
|
||||||
|
connection_timers: list[PlayerTimer] = []
|
||||||
|
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
connection_timers.append({"team": team, "player": player, "time": None})
|
||||||
|
|
||||||
|
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
|
||||||
|
# find the matching entry
|
||||||
|
for entry in connection_timers:
|
||||||
|
if entry["team"] == team and entry["player"] == player:
|
||||||
|
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
break
|
||||||
|
|
||||||
|
player_status: list[PlayerStatus] = []
|
||||||
|
"""The current client status for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"aliases": player_aliases,
|
||||||
|
"player_items_received": player_items_received,
|
||||||
|
"player_checks_done": player_checks_done,
|
||||||
|
"total_checks_done": total_checks_done,
|
||||||
|
"hints": hints,
|
||||||
|
"activity_timers": activity_timers,
|
||||||
|
"connection_timers": connection_timers,
|
||||||
|
"player_status": player_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerGroups(TypedDict):
|
||||||
|
slot: int
|
||||||
|
name: str
|
||||||
|
members: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSlotData(TypedDict):
|
||||||
|
player: int
|
||||||
|
slot_data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route("/static_tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=300)
|
||||||
|
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
groups: list[PlayerGroups] = []
|
||||||
|
"""The Slot ID of groups and the IDs of the group's members."""
|
||||||
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
for player in players:
|
||||||
|
slot_info = tracker_data.get_slot_info(player)
|
||||||
|
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||||
|
continue
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"slot": player,
|
||||||
|
"name": slot_info.name,
|
||||||
|
"members": list(slot_info.group_members),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
player_locations_total: list[PlayerLocationsTotal] = []
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_locations_total.append(
|
||||||
|
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groups": groups,
|
||||||
|
"datapackage": tracker_data._multidata["datapackage"],
|
||||||
|
"player_locations_total": player_locations_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
# It should be exceedingly rare that slot data is needed, so it's separated out.
|
||||||
|
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=300)
|
||||||
|
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Slot data for all players in the room. Typing completely arbitrary per game.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
slot_data: list[PlayerSlotData] = []
|
||||||
|
"""Slot data for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
|
||||||
|
break
|
||||||
|
|
||||||
|
return slot_data
|
||||||
@@ -12,12 +12,11 @@ from flask import flash, redirect, render_template, request, session, url_for
|
|||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name
|
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__, restricted_dumps
|
from Utils import __version__, restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
@@ -129,36 +128,39 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
erargs = parse_arguments(['--multi', str(playercount)])
|
args = mystery_argparse()
|
||||||
erargs.seed = seed
|
args.multi = playercount
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
args.seed = seed
|
||||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.race = race
|
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
erargs.outputname = seedname
|
args.race = race
|
||||||
erargs.outputpath = target.name
|
args.outputname = seedname
|
||||||
erargs.teams = 1
|
args.outputpath = target.name
|
||||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
args.teams = 1
|
||||||
{"bosses", "items", "connections", "texts"}))
|
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
erargs.skip_prog_balancing = False
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_output = False
|
args.skip_prog_balancing = False
|
||||||
erargs.spoiler_only = False
|
args.skip_output = False
|
||||||
erargs.csv_output = False
|
args.spoiler_only = False
|
||||||
|
args.csv_output = False
|
||||||
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
for k, v in settings.items():
|
for k, v in settings.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
if hasattr(erargs, k):
|
if hasattr(args, k):
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
else:
|
else:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
|
|
||||||
if not erargs.name[player]:
|
if not args.name[player]:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(args.name.values())) != len(args.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import threading
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
def update_sprites_lttp():
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
from tkinter import Tk
|
from tkinter import Tk
|
||||||
from LttPAdjuster import get_image_for_sprite
|
from LttPAdjuster import get_image_for_sprite
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
|
|||||||
@@ -87,19 +87,22 @@ def start_playing():
|
|||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
"""Game Info Pages"""
|
"""Game Info Pages"""
|
||||||
theme = get_world_theme(game)
|
try:
|
||||||
secure_game_name = secure_filename(game)
|
theme = get_world_theme(game)
|
||||||
lang = secure_filename(lang)
|
secure_game_name = secure_filename(game)
|
||||||
document = render_markdown(os.path.join(
|
lang = secure_filename(lang)
|
||||||
app.static_folder, "generated", "docs",
|
document = render_markdown(os.path.join(
|
||||||
secure_game_name, f"{lang}_{secure_game_name}.md"
|
app.static_folder, "generated", "docs",
|
||||||
))
|
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||||
return render_template(
|
))
|
||||||
"markdown_document.html",
|
return render_template(
|
||||||
title=f"{game} Guide",
|
"markdown_document.html",
|
||||||
html_from_markdown=document,
|
title=f"{game} Guide",
|
||||||
theme=theme,
|
html_from_markdown=document,
|
||||||
)
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@@ -112,19 +115,31 @@ def games():
|
|||||||
@app.route('/tutorial/<string:game>/<string:file>')
|
@app.route('/tutorial/<string:game>/<string:file>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial(game: str, file: str):
|
def tutorial(game: str, file: str):
|
||||||
theme = get_world_theme(game)
|
try:
|
||||||
secure_game_name = secure_filename(game)
|
theme = get_world_theme(game)
|
||||||
file = secure_filename(file)
|
secure_game_name = secure_filename(game)
|
||||||
document = render_markdown(os.path.join(
|
file = secure_filename(file)
|
||||||
app.static_folder, "generated", "docs",
|
document = render_markdown(os.path.join(
|
||||||
secure_game_name, file+".md"
|
app.static_folder, "generated", "docs",
|
||||||
))
|
secure_game_name, file+".md"
|
||||||
return render_template(
|
))
|
||||||
"markdown_document.html",
|
return render_template(
|
||||||
title=f"{game} Guide",
|
"markdown_document.html",
|
||||||
html_from_markdown=document,
|
title=f"{game} Guide",
|
||||||
theme=theme,
|
html_from_markdown=document,
|
||||||
)
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
|
def tutorial_redirect(game: str, file: str, lang: str):
|
||||||
|
"""
|
||||||
|
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
|
||||||
|
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
|
||||||
|
"""
|
||||||
|
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@@ -245,7 +260,10 @@ def host_room(room: UUID):
|
|||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||||
with db_session:
|
|
||||||
|
if now - room.last_activity > datetime.timedelta(minutes=1):
|
||||||
|
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
||||||
|
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ def generate_weighted_yaml(game: str):
|
|||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
for key, val in request.form.items():
|
for key, val in request.form.items():
|
||||||
if "||" not in key:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = {}
|
||||||
|
elif "||" not in key:
|
||||||
if len(str(val)) == 0:
|
if len(str(val)) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -212,8 +214,11 @@ def generate_yaml(game: str):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
options = {}
|
options = {}
|
||||||
intent_generate = False
|
intent_generate = False
|
||||||
|
|
||||||
for key, val in request.form.items(multi=True):
|
for key, val in request.form.items(multi=True):
|
||||||
if key in options:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = []
|
||||||
|
elif options.get(key):
|
||||||
if not isinstance(options[key], list):
|
if not isinstance(options[key], list):
|
||||||
options[key] = [options[key]]
|
options[key] = [options[key]]
|
||||||
options[key].append(val)
|
options[key].append(val)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
flask>=3.1.1
|
flask>=3.1.1
|
||||||
werkzeug>=3.1.3
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19
|
pony>=0.7.19; python_version <= '3.12'
|
||||||
|
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||||
waitress>=3.0.2
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.17
|
Flask-Compress>=1.17
|
||||||
|
|||||||
@@ -1,49 +1,43 @@
|
|||||||
|
let updateSection = (sectionName, fakeDOM) => {
|
||||||
|
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
// Reload tracker every 15 seconds
|
// Reload tracker every 60 seconds (sync'd)
|
||||||
const url = window.location;
|
const url = window.location;
|
||||||
setInterval(() => {
|
// Note: This synchronization code is adapted from code in trackerCommon.js
|
||||||
const ajax = new XMLHttpRequest();
|
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
||||||
ajax.onreadystatechange = () => {
|
console.log("Target second of refresh: " + targetSecond);
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
let getSleepTimeSeconds = () => {
|
||||||
const domParser = new DOMParser();
|
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
||||||
|
return sleepSeconds || 60;
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
let updateTracker = () => {
|
||||||
const categories = document.getElementsByClassName("location-category");
|
const ajax = new XMLHttpRequest();
|
||||||
for (let category of categories) {
|
ajax.onreadystatechange = () => {
|
||||||
let hide_id = category.id.split('_')[0];
|
if (ajax.readyState !== 4) { return; }
|
||||||
if (hide_id === 'Total') {
|
|
||||||
continue;
|
// Create a fake DOM using the returned HTML
|
||||||
}
|
const domParser = new DOMParser();
|
||||||
category.addEventListener('click', function() {
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
// Update dynamic sections
|
||||||
// Change text of the header
|
updateSection('player-info', fakeDOM);
|
||||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
updateSection('section-filler', fakeDOM);
|
||||||
const orig_text = tab_header.innerHTML;
|
updateSection('section-terran', fakeDOM);
|
||||||
let new_text;
|
updateSection('section-zerg', fakeDOM);
|
||||||
if (orig_text.includes("▼")) {
|
updateSection('section-protoss', fakeDOM);
|
||||||
new_text = orig_text.replace("▼", "▲");
|
updateSection('section-nova', fakeDOM);
|
||||||
}
|
updateSection('section-kerrigan', fakeDOM);
|
||||||
else {
|
updateSection('section-keys', fakeDOM);
|
||||||
new_text = orig_text.replace("▲", "▼");
|
updateSection('section-locations', fakeDOM);
|
||||||
}
|
};
|
||||||
tab_header.innerHTML = new_text;
|
ajax.open('GET', url);
|
||||||
});
|
ajax.send();
|
||||||
}
|
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
|
};
|
||||||
|
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Regular, sans-serif;
|
font-family: LondrinaSolid-Regular, sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-shadow: 1px 1px 4px #000000;
|
text-shadow: 1px 1px 4px #000000;
|
||||||
}
|
}
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -50,7 +48,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +63,12 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h6, .markdown details summary.h6{
|
.markdown h6, .markdown details summary.h6{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown h5, .markdown h6{
|
.markdown h4, .markdown h5, .markdown h6{
|
||||||
|
|||||||
@@ -1,160 +1,279 @@
|
|||||||
#player-tracker-wrapper{
|
*{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
--icon-size: 36px;
|
||||||
|
--item-class-padding: 4px;
|
||||||
|
}
|
||||||
|
a{
|
||||||
|
color: #1ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table td {
|
/* Section colours */
|
||||||
vertical-align: top;
|
#player-info{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
.player-tracker{
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.tracker-section{
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
#terran-items{
|
||||||
|
background-color: #3a7;
|
||||||
|
}
|
||||||
|
#zerg-items{
|
||||||
|
background-color: #d94;
|
||||||
|
}
|
||||||
|
#protoss-items{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
#nova-items{
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
#kerrigan-items{
|
||||||
|
background-color: #a37;
|
||||||
|
}
|
||||||
|
#keys{
|
||||||
|
background-color: #aa2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area{
|
/* Sections */
|
||||||
border: 2px solid #000000;
|
.section-body{
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
padding: 3px 10px 3px 10px;
|
flex-flow: row wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.section-body-2{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.section-title{
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 3px solid black;
|
||||||
|
/* Prevent text selection */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
input[type="checkbox"]{
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.section-title:hover h2{
|
||||||
|
text-shadow: 0 0 4px #ddd;
|
||||||
|
}
|
||||||
|
.f {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-terran) {
|
/* Acquire item filters */
|
||||||
width: 690px;
|
.tracker-section img{
|
||||||
background-color: #525494;
|
height: 100%;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
.unacquired, .lvl-0 .f{
|
||||||
|
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
|
||||||
|
}
|
||||||
|
.spacer{
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-zerg) {
|
/* Item groups */
|
||||||
width: 360px;
|
.item-class{
|
||||||
background-color: #9d60d2;
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--item-class-padding);
|
||||||
|
}
|
||||||
|
.item-class-header{
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
.item-class-upgrades{
|
||||||
|
/* Note: {display: flex; flex-flow: column wrap} */
|
||||||
|
/* just breaks on Firefox (width does not scale to content) */
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
grid-auto-flow: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-protoss) {
|
/* Subsections */
|
||||||
width: 400px;
|
.section-toc{
|
||||||
background-color: #d2b260;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.toc-box{
|
||||||
|
position: relative;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
.toc-box:hover{
|
||||||
|
text-shadow: 0 0 7px white;
|
||||||
|
}
|
||||||
|
.ss-header{
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
writing-mode: sideways-lr;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: 115%;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table .inventory-table td{
|
/* Progressive items */
|
||||||
width: 40px;
|
.progressive{
|
||||||
height: 40px;
|
max-height: var(--icon-size);
|
||||||
text-align: center;
|
display: contents;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table td.title{
|
.lvl-0 > :nth-child(2),
|
||||||
padding-top: 10px;
|
.lvl-0 > :nth-child(3),
|
||||||
height: 20px;
|
.lvl-0 > :nth-child(4),
|
||||||
font-family: "JuraBook", monospace;
|
.lvl-0 > :nth-child(5){
|
||||||
font-size: 16px;
|
display: none;
|
||||||
font-weight: bold;
|
}
|
||||||
|
.lvl-1 > :nth-child(2),
|
||||||
|
.lvl-1 > :nth-child(3),
|
||||||
|
.lvl-1 > :nth-child(4),
|
||||||
|
.lvl-1 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-2 > :nth-child(1),
|
||||||
|
.lvl-2 > :nth-child(3),
|
||||||
|
.lvl-2 > :nth-child(4),
|
||||||
|
.lvl-2 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-3 > :nth-child(1),
|
||||||
|
.lvl-3 > :nth-child(2),
|
||||||
|
.lvl-3 > :nth-child(4),
|
||||||
|
.lvl-3 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-4 > :nth-child(1),
|
||||||
|
.lvl-4 > :nth-child(2),
|
||||||
|
.lvl-4 > :nth-child(3),
|
||||||
|
.lvl-4 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-5 > :nth-child(1),
|
||||||
|
.lvl-5 > :nth-child(2),
|
||||||
|
.lvl-5 > :nth-child(3),
|
||||||
|
.lvl-5 > :nth-child(4){
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img{
|
/* Filler item counters */
|
||||||
height: 100%;
|
.item-counter{
|
||||||
max-width: 40px;
|
display: table;
|
||||||
max-height: 40px;
|
text-align: center;
|
||||||
border: 1px solid #000000;
|
padding: var(--item-class-padding);
|
||||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
}
|
||||||
background-color: black;
|
.item-count{
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding-left: 3px;
|
||||||
|
padding-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img.acquired{
|
/* Hidden items */
|
||||||
filter: none;
|
.hidden-class:not(:has(img.acquired)){
|
||||||
background-color: black;
|
display: none;
|
||||||
|
}
|
||||||
|
.hidden-item:not(.acquired){
|
||||||
|
display:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-terran img.acquired {
|
/* Keys */
|
||||||
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
#keys ol, #keys ul{
|
||||||
|
columns: 3;
|
||||||
|
-webkit-columns: 3;
|
||||||
|
-moz-columns: 3;
|
||||||
|
}
|
||||||
|
#keys li{
|
||||||
|
padding-right: 15pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-protoss img.acquired {
|
/* Locations */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
#section-locations{
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 120ch){
|
||||||
|
#section-locations ul{
|
||||||
|
columns: 2;
|
||||||
|
-webkit-columns: 2;
|
||||||
|
-moz-columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#locations li.checked{
|
||||||
|
list-style-type: "✔ ";
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-level-1 img.acquired {
|
/* Allowing scrolling down a little further */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
.bottom-padding{
|
||||||
}
|
min-height: 33vh;
|
||||||
|
}
|
||||||
.inventory-table .tint-level-2 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-level-3 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.item-count {
|
|
||||||
width: 160px;
|
|
||||||
text-align: left;
|
|
||||||
color: black;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
border: 2px solid #000000;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #87b678;
|
|
||||||
padding: 10px 3px 3px;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table table{
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td:has(.location-column) {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column .spacer {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.finding_player == player %}
|
{% if hint.finding_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||||
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
|
{% elif get_slot_info(hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.receiving_player == player %}
|
{% if hint.receiving_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||||
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
|
{% elif get_slot_info(hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||||
|
|||||||
@@ -45,15 +45,15 @@
|
|||||||
{%- set current_sphere = loop.index %}
|
{%- set current_sphere = loop.index %}
|
||||||
{%- for player, sphere_location_ids in sphere.items() %}
|
{%- for player, sphere_location_ids in sphere.items() %}
|
||||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||||
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
{%- set finder_game = tracker_data.get_player_game(player) %}
|
||||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
{%- set player_location_data = tracker_data.get_player_locations(player) %}
|
||||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||||
<tr>
|
<tr>
|
||||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
||||||
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
|
||||||
<td>{{ current_sphere }}</td>
|
<td>{{ current_sphere }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
<td>{{ tracker_data.get_player_name(player) }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
<td>{{ tracker_data.get_player_name(receiver) }}</td>
|
||||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
||||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
||||||
<td>{{ finder_game }}</td>
|
<td>{{ finder_game }}</td>
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
-%}
|
-%}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(team, hint.finding_player).type == 2 %}
|
{% if get_slot_info(hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
|
{% if get_slot_info(hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -169,6 +171,7 @@
|
|||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
|
|||||||
@@ -11,32 +11,32 @@
|
|||||||
<h1>Site Map</h1>
|
<h1>Site Map</h1>
|
||||||
<h2>Base Pages</h2>
|
<h2>Base Pages</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/discord">Discord Link</a></li>
|
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
|
||||||
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
|
||||||
<li><a href="/favicon.ico">Favicon</a></li>
|
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
|
||||||
<li><a href="/generate">Generate Game Page</a></li>
|
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
|
||||||
<li><a href="/">Homepage</a></li>
|
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
|
||||||
<li><a href="/uploads">Host Game Page</a></li>
|
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
|
||||||
<li><a href="/datapackage">Raw Data Package</a></li>
|
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
|
||||||
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
|
||||||
<li><a href="/sitemap">Site Map</a></li>
|
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
|
||||||
<li><a href="/start-playing">Start Playing</a></li>
|
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
|
||||||
<li><a href="/games">Supported Games Page</a></li>
|
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
|
||||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
|
||||||
<li><a href="/user-content">User Content</a></li>
|
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
|
||||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
|
||||||
<li><a href="/glossary/en">Glossary</a></li>
|
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
|
||||||
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
|
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Tutorials</h2>
|
<h2>Tutorials</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Game Info Pages</h2>
|
<h2>Game Info Pages</h2>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
waitSeedDiv.innerHTML = `
|
if (data.queue_len === 1){
|
||||||
<h1>Generation in Progress</h1>
|
waitSeedDiv.innerHTML = `
|
||||||
<p>${data.text}</p>
|
<h1>Generation in Progress</h1>
|
||||||
`;
|
<p>${data.text}</p>
|
||||||
|
<p>This is the only generation in the queue.</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Generation in Progress</h1>
|
||||||
|
<p>${data.text}</p>
|
||||||
|
<p>There are ${data.queue_len} generations in the queue.</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
setTimeout(checkStatus, 1000); // Continue polling.
|
setTimeout(checkStatus, 1000); // Continue polling.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="list-container">
|
<div class="list-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="list-entry">
|
<div class="list-entry">
|
||||||
@@ -158,6 +159,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro LocationSet(option_name, option, world) %}
|
{% macro LocationSet(option_name, option, world) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -180,6 +182,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemSet(option_name, option, world) %}
|
{% macro ItemSet(option_name, option, world) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -202,6 +205,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="set-entry">
|
<div class="set-entry">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@ from worlds.tloz.Items import item_game_ids
|
|||||||
from worlds.tloz.Locations import location_ids
|
from worlds.tloz.Locations import location_ids
|
||||||
from worlds.tloz import Items, Locations, Rom
|
from worlds.tloz import Items, Locations, Rom
|
||||||
|
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
||||||
@@ -341,13 +343,12 @@ if __name__ == '__main__':
|
|||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
Utils.init_logging("ZeldaClient")
|
Utils.init_logging("ZeldaClient")
|
||||||
|
|
||||||
options = Utils.get_options()
|
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
|
||||||
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = typing.cast(typing.Union[bool, str],
|
||||||
Utils.get_options()["tloz_options"].get("rom_start", True))
|
get_settings()["tloz_options"].get("rom_start", True))
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
@@ -220,6 +220,8 @@
|
|||||||
<MessageBoxLabel>:
|
<MessageBoxLabel>:
|
||||||
theme_text_color: "Custom"
|
theme_text_color: "Custom"
|
||||||
text_color: 1, 1, 1, 1
|
text_color: 1, 1, 1, 1
|
||||||
|
<MessageBox>:
|
||||||
|
height: self.content.texture_size[1] + 80
|
||||||
<ScrollBox>:
|
<ScrollBox>:
|
||||||
layout: layout
|
layout: layout
|
||||||
bar_width: "12dp"
|
bar_width: "12dp"
|
||||||
@@ -233,8 +235,3 @@
|
|||||||
spacing: 10
|
spacing: 10
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: self.minimum_height
|
height: self.minimum_height
|
||||||
<MessageBoxLabel>:
|
|
||||||
valign: "middle"
|
|
||||||
halign: "center"
|
|
||||||
text_size: self.width, None
|
|
||||||
height: self.texture_size[1]
|
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ description: {{ yaml_dump("Default %s Template" % game) }}
|
|||||||
game: {{ yaml_dump(game) }}
|
game: {{ yaml_dump(game) }}
|
||||||
requires:
|
requires:
|
||||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||||
|
{%- if world_version != "0.0.0" %}
|
||||||
|
game:
|
||||||
|
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
{%- macro range_option(option) %}
|
{%- macro range_option(option) %}
|
||||||
# You can define additional values between the minimum and maximum values.
|
# You can define additional values between the minimum and maximum values.
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
author: Nintendo
|
|
||||||
data: null
|
|
||||||
game: A Link to the Past
|
|
||||||
min_format_version: 1
|
|
||||||
name: Link
|
|
||||||
format_version: 1
|
|
||||||
sprite_version: 1
|
|
||||||
2
data/sprites/remote/.gitignore
vendored
2
data/sprites/remote/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -21,9 +21,6 @@
|
|||||||
# Aquaria
|
# Aquaria
|
||||||
/worlds/aquaria/ @tioui
|
/worlds/aquaria/ @tioui
|
||||||
|
|
||||||
# ArchipIDLE
|
|
||||||
/worlds/archipidle/ @LegendaryLinux
|
|
||||||
|
|
||||||
# Blasphemous
|
# Blasphemous
|
||||||
/worlds/blasphemous/ @TRPG0
|
/worlds/blasphemous/ @TRPG0
|
||||||
|
|
||||||
@@ -42,9 +39,15 @@
|
|||||||
# Celeste 64
|
# Celeste 64
|
||||||
/worlds/celeste64/ @PoryGone
|
/worlds/celeste64/ @PoryGone
|
||||||
|
|
||||||
|
# Celeste (Open World)
|
||||||
|
/worlds/celeste_open_world/ @PoryGone
|
||||||
|
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
/worlds/checksfinder/ @SunCatMC
|
/worlds/checksfinder/ @SunCatMC
|
||||||
|
|
||||||
|
# Choo-Choo Charles
|
||||||
|
/worlds/cccharles/ @Yaranorgoth
|
||||||
|
|
||||||
# Civilization VI
|
# Civilization VI
|
||||||
/worlds/civ6/ @hesto2
|
/worlds/civ6/ @hesto2
|
||||||
|
|
||||||
@@ -69,6 +72,9 @@
|
|||||||
# Faxanadu
|
# Faxanadu
|
||||||
/worlds/faxanadu/ @Daivuk
|
/worlds/faxanadu/ @Daivuk
|
||||||
|
|
||||||
|
# Final Fantasy (1)
|
||||||
|
/worlds/ff1/ @Rosalie-A
|
||||||
|
|
||||||
# Final Fantasy Mystic Quest
|
# Final Fantasy Mystic Quest
|
||||||
/worlds/ffmq/ @Alchav @wildham0
|
/worlds/ffmq/ @Alchav @wildham0
|
||||||
|
|
||||||
@@ -238,9 +244,6 @@
|
|||||||
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
||||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||||
|
|
||||||
# Final Fantasy (1)
|
|
||||||
# /worlds/ff1/
|
|
||||||
|
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
# /worlds/oot/
|
# /worlds/oot/
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ if possible.
|
|||||||
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
||||||
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
||||||
|
|
||||||
|
### Launcher Integration
|
||||||
|
|
||||||
|
If you have a python client or want to utilize the integration features of the Archipelago Launcher (ex. Slot links in
|
||||||
|
webhost) you can define a Component to be a part of the Launcher. `LauncherComponents.components` can be appended to
|
||||||
|
with additional Components in order to automatically add them to the Launcher. Most Components only need a
|
||||||
|
`display_name` and `func`, but `supports_uri` and `game_name` can be defined to support launching by webhost links,
|
||||||
|
`icon` and `description` can be used to customize display in the Launcher UI, and `file_identifier` can be used to
|
||||||
|
launch by file.
|
||||||
|
|
||||||
|
Additionally, if you use `func` you have access to LauncherComponent.launch or launch_subprocess to run your
|
||||||
|
function as a subprocesses that can be utilized side by side other clients.
|
||||||
|
```py
|
||||||
|
def my_func(*args: str):
|
||||||
|
from .client import run_client
|
||||||
|
LauncherComponent.launch(run_client, name="My Client", args=args)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## World
|
## World
|
||||||
|
|
||||||
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
|
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
|
||||||
|
|||||||
@@ -19,7 +19,21 @@ the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__i
|
|||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
No metadata is specified yet.
|
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
|
||||||
|
The current format version has at minimum:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 6,
|
||||||
|
"compatible_version": 5,
|
||||||
|
"game": "Game Name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
|
||||||
|
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
||||||
|
Archipelago version respectively to filter those files from being loaded
|
||||||
|
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
||||||
|
An apworld without a world_version is always treated as older than one with a version
|
||||||
|
|
||||||
|
|
||||||
## Extra Data
|
## Extra Data
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ game contributions:
|
|||||||
* **Do not introduce unit test failures/regressions.**
|
* **Do not introduce unit test failures/regressions.**
|
||||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||||
your changes. Currently, the oldest supported version
|
your changes. Currently, the oldest supported version
|
||||||
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
|
is [Python 3.11](https://www.python.org/downloads/release/python-31113/).
|
||||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||||
pushing.
|
pushing.
|
||||||
You can turn them on here:
|
You can turn them on here:
|
||||||
|
|||||||
@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
|
|||||||
|
|
||||||
Terrain matching or dungeon shuffle:
|
Terrain matching or dungeon shuffle:
|
||||||
```python
|
```python
|
||||||
def randomize_within_same_group(group: int) -> List[int]:
|
def randomize_within_same_group(group: int) -> list[int]:
|
||||||
return [group]
|
return [group]
|
||||||
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
||||||
```
|
```
|
||||||
|
|
||||||
Directional + area shuffle:
|
Directional + area shuffle:
|
||||||
```python
|
```python
|
||||||
def get_target_groups(group: int) -> List[int]:
|
def get_target_groups(group: int) -> list[int]:
|
||||||
# example group: LEFT | CAVE
|
# example group: LEFT | CAVE
|
||||||
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
||||||
direction = group & Groups.DIRECTION_MASK
|
direction = group & Groups.DIRECTION_MASK
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Sent to clients when they connect to an Archipelago server.
|
|||||||
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
|
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
|
||||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
||||||
| password | bool | Denoted whether a password is required to join this room. |
|
| password | bool | Denoted whether a password is required to join this room. |
|
||||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
| permissions | dict\[str, [Permission](#Permission)\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||||
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
||||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
||||||
| games | list\[str\] | List of games present in this multiworld. |
|
| games | list\[str\] | List of games present in this multiworld. |
|
||||||
@@ -662,13 +662,14 @@ class SlotType(enum.IntFlag):
|
|||||||
An object representing static information about a slot.
|
An object representing static information about a slot.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import typing
|
from collections.abc import Sequence
|
||||||
|
from typing import NamedTuple
|
||||||
from NetUtils import SlotType
|
from NetUtils import SlotType
|
||||||
class NetworkSlot(typing.NamedTuple):
|
class NetworkSlot(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: typing.List[int] = [] # only populated if type == group
|
group_members: Sequence[int] = [] # only populated if type == group
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission
|
### Permission
|
||||||
@@ -686,8 +687,8 @@ class Permission(enum.IntEnum):
|
|||||||
### Hint
|
### Hint
|
||||||
An object representing a Hint.
|
An object representing a Hint.
|
||||||
```python
|
```python
|
||||||
import typing
|
from typing import NamedTuple
|
||||||
class Hint(typing.NamedTuple):
|
class Hint(NamedTuple):
|
||||||
receiving_player: int
|
receiving_player: int
|
||||||
finding_player: int
|
finding_player: int
|
||||||
location: int
|
location: int
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you
|
|||||||
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
||||||
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
||||||
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
||||||
`worlds.alttp.options.py`
|
`worlds/alttp/Options.py`
|
||||||
|
|
||||||
### OptionDict
|
### OptionDict
|
||||||
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use that version. These steps are for developers or platforms without compiled r
|
|||||||
## General
|
## General
|
||||||
|
|
||||||
What you'll need:
|
What you'll need:
|
||||||
* [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version
|
* [Python 3.11.9 or newer](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
|
* On Windows, please consider only using the latest supported version in production environments since security
|
||||||
updates for older versions are not easily available.
|
updates for older versions are not easily available.
|
||||||
* Python 3.12.x is currently the newest supported version
|
* Python 3.13.x is currently the newest supported version
|
||||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||||
* Matching C compiler
|
* Matching C compiler
|
||||||
* possibly optional, read operating system specific sections
|
* possibly optional, read operating system specific sections
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if it does not exist.
|
|||||||
## Global Settings
|
## Global Settings
|
||||||
|
|
||||||
All non-world-specific settings are defined directly in settings.py.
|
All non-world-specific settings are defined directly in settings.py.
|
||||||
Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`.
|
Each value needs to have a default. If the default should be `None`, annotate it using `T | None = None`.
|
||||||
|
|
||||||
To access a "global" config value, with correct typing, use one of
|
To access a "global" config value, with correct typing, use one of
|
||||||
```python
|
```python
|
||||||
|
|||||||
18
docs/shared_cache.md
Normal file
18
docs/shared_cache.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Shared Cache
|
||||||
|
|
||||||
|
Archipelago maintains a shared folder of information that can be persisted for a machine and reused across Libraries.
|
||||||
|
It can be found at the User Cache Directory for appname `Archipelago` in the `Cache` subfolder
|
||||||
|
(ex. `%LOCALAPPDATA%/Archipelago/Cache`).
|
||||||
|
|
||||||
|
## Common Cache
|
||||||
|
|
||||||
|
The Common Cache `common.json` can be used to store any generic data that is expected to be shared across programs
|
||||||
|
for the same User.
|
||||||
|
|
||||||
|
* `uuid`: A UUID identifier used to identify clients as from the same user/machine, to be sent in the Connect packet
|
||||||
|
|
||||||
|
## Data Package Cache
|
||||||
|
|
||||||
|
The `datapackage` folder in the shared cache folder is used to store datapackages by game and checksum to be reused
|
||||||
|
in order to save network traffic. The expected structure is `datapackage/Game Name/checksum_value.json` with the
|
||||||
|
contents of each json file being the no-whitespace datapackage contents.
|
||||||
@@ -15,8 +15,10 @@
|
|||||||
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
||||||
use single quotes inside them: `f"Like {dct['key']}"`
|
use single quotes inside them: `f"Like {dct['key']}"`
|
||||||
* Use type annotations where possible for function signatures and class members.
|
* Use type annotations where possible for function signatures and class members.
|
||||||
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
|
* Use type annotations where appropriate for local variables (e.g. `var: list[int] = []`, or when the
|
||||||
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
|
type is hard or impossible to deduce). Clear annotations help developers look up and validate API calls.
|
||||||
|
* Prefer new style type annotations for new code (e.g. `var: dict[str, str | int]` over
|
||||||
|
`var: Dict[str, Union[str, int]]`).
|
||||||
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
|
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
|
||||||
beginning of a line at the same indentation as the beginning of the line with the open bracket.
|
beginning of a line at the same indentation as the beginning of the line with the open bracket.
|
||||||
```python
|
```python
|
||||||
@@ -60,3 +62,9 @@
|
|||||||
* Indent `case` inside `switch ` with 2 spaces.
|
* Indent `case` inside `switch ` with 2 spaces.
|
||||||
* Use single quotes.
|
* Use single quotes.
|
||||||
* Semicolons are required after every statement.
|
* Semicolons are required after every statement.
|
||||||
|
|
||||||
|
## KV
|
||||||
|
|
||||||
|
* Style should be defined in `.kv` as much as possible, only Python when unavailable.
|
||||||
|
* Should follow [our Python style](#python-code) where appropriate (quotation marks, indentation).
|
||||||
|
* When escaping a line break, add a space between code and backslash.
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ overridden. For more information on what methods are available to your class, ch
|
|||||||
|
|
||||||
#### Alternatives to WorldTestBase
|
#### Alternatives to WorldTestBase
|
||||||
|
|
||||||
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
Unit tests can also be created using
|
||||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) directly. These may be useful
|
||||||
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
for generating a multiworld under very specific constraints without using the generic world setup, or for testing
|
||||||
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
portions of your code that can be tested without relying on a multiworld to be created first.
|
||||||
|
|
||||||
#### Parametrization
|
#### Parametrization
|
||||||
|
|
||||||
@@ -102,8 +102,7 @@ for multiple inputs) the base test. Some important things to consider when attem
|
|||||||
|
|
||||||
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
||||||
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
||||||
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
|
extra CPU time. Consider using `unittest.TestCase` directly or setting `WorldTestBase.run_default_tests` to False.
|
||||||
or setting `WorldTestBase.run_default_tests` to False.
|
|
||||||
|
|
||||||
#### Performance Considerations
|
#### Performance Considerations
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ Current endpoints:
|
|||||||
- [`/status/<suuid:seed>`](#status)
|
- [`/status/<suuid:seed>`](#status)
|
||||||
- Room API
|
- Room API
|
||||||
- [`/room_status/<suuid:room_id>`](#roomstatus)
|
- [`/room_status/<suuid:room_id>`](#roomstatus)
|
||||||
|
- Tracker API
|
||||||
|
- [`/tracker/<suuid:tracker>`](#tracker)
|
||||||
|
- [`/static_tracker/<suuid:tracker>`](#statictracker)
|
||||||
|
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
|
||||||
- User API
|
- User API
|
||||||
- [`/get_rooms`](#getrooms)
|
- [`/get_rooms`](#getrooms)
|
||||||
- [`/get_seeds`](#getseeds)
|
- [`/get_seeds`](#getseeds)
|
||||||
@@ -244,6 +248,212 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tracker Endpoints
|
||||||
|
Endpoints to fetch information regarding players of an active WebHost room with the supplied tracker_ID. The tracker ID
|
||||||
|
can either be viewed while on a room tracker page, or from the [room's endpoint](#room-endpoints).
|
||||||
|
|
||||||
|
### `/tracker/<suuid:tracker>`
|
||||||
|
<a name=tracker></a>
|
||||||
|
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 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`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aliases": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"alias": "Incompetence"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"alias": "Slot_Name_2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"player_items_received": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"items": [
|
||||||
|
[1, 1, 1, 0],
|
||||||
|
[2, 2, 2, 1]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"items": [
|
||||||
|
[1, 1, 1, 2],
|
||||||
|
[2, 2, 2, 0]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"player_checks_done": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"locations": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"locations": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_checks_done": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"checks_done": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hints": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"hints": [
|
||||||
|
[1, 2, 4, 6, 0, "", 4, 0]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"hints": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"activity_timers": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connection_timers": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"player_status": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 1,
|
||||||
|
"status": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"player": 2,
|
||||||
|
"status": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/static_tracker/<suuid:tracker>`
|
||||||
|
<a name=statictracker></a>
|
||||||
|
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`)
|
||||||
|
- 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`)
|
||||||
|
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"slot": 5,
|
||||||
|
"name": "testGroup",
|
||||||
|
"members": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 6,
|
||||||
|
"name": "myCoolLink",
|
||||||
|
"members": [
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"datapackage": {
|
||||||
|
"Archipelago": {
|
||||||
|
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
|
||||||
|
},
|
||||||
|
"The Messenger": {
|
||||||
|
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"player_locations_total": [
|
||||||
|
{
|
||||||
|
"player": 1,
|
||||||
|
"team" : 0,
|
||||||
|
"total_locations": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player": 2,
|
||||||
|
"team" : 0,
|
||||||
|
"total_locations": 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/slot_data_tracker/<suuid:tracker>`
|
||||||
|
<a name=slotdatatracker></a>
|
||||||
|
Will provide a list of each player's slot_data.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"player": 1,
|
||||||
|
"slot_data": {
|
||||||
|
"example_option": 1,
|
||||||
|
"other_option": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player": 2,
|
||||||
|
"slot_data": {
|
||||||
|
"example_option": 1,
|
||||||
|
"other_option": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
## User Endpoints
|
## User Endpoints
|
||||||
User endpoints can get room and seed details from the current session tokens (cookies)
|
User endpoints can get room and seed details from the current session tokens (cookies)
|
||||||
|
|
||||||
@@ -344,4 +554,4 @@ Example:
|
|||||||
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -76,8 +76,8 @@ webhost:
|
|||||||
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
||||||
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
||||||
|
|
||||||
* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
|
* `options_presets` (optional) `dict[str, dict[str, Any]]` where the keys are the names of the presets and the values
|
||||||
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names
|
are the options to be set for that preset. The options are defined as a `dict[str, Any]` where the keys are the names
|
||||||
of the options and the values are the values to be set for that option. These presets will be available for users to
|
of the options and the values are the values to be set for that option. These presets will be available for users to
|
||||||
select from on the game's options page.
|
select from on the game's options page.
|
||||||
|
|
||||||
@@ -257,6 +257,14 @@ another flag like "progression", it means "an especially useful progression item
|
|||||||
combined with `progression`; see below)
|
combined with `progression`; see below)
|
||||||
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
|
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
|
||||||
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
|
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
|
||||||
|
* `deprioritized`: denotes that an item should not be placed on priority locations
|
||||||
|
(to be combined with `progression`; see below)
|
||||||
|
* `progression_deprioritized`: the combination of `progression` and `deprioritized`, i.e. a progression item that
|
||||||
|
should not be placed on priority locations, despite being progression;
|
||||||
|
like skip_balancing, this is commonly used for currency or tokens.
|
||||||
|
* `progression_deprioritized_skip_balancing`: the combination of `progression`, `deprioritized` and `skip_balancing`.
|
||||||
|
Since there is overlap between the kind of items that want `skip_balancing` and `deprioritized`,
|
||||||
|
this combined classification exists for convenience
|
||||||
|
|
||||||
### Regions
|
### Regions
|
||||||
|
|
||||||
@@ -745,7 +753,7 @@ from BaseClasses import CollectionState, MultiWorld
|
|||||||
from worlds.AutoWorld import LogicMixin
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
class MyGameState(LogicMixin):
|
class MyGameState(LogicMixin):
|
||||||
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
|
mygame_defeatable_enemies: dict[int, set[str]] # per player
|
||||||
|
|
||||||
def init_mixin(self, multiworld: MultiWorld) -> None:
|
def init_mixin(self, multiworld: MultiWorld) -> None:
|
||||||
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
||||||
@@ -874,11 +882,11 @@ item/location pairs is unnecessary since the AP server already retains and freel
|
|||||||
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def fill_slot_data(self) -> Dict[str, Any]:
|
def fill_slot_data(self) -> dict[str, Any]:
|
||||||
# In order for our game client to handle the generated seed correctly we need to know what the user selected
|
# In order for our game client to handle the generated seed correctly we need to know what the user selected
|
||||||
# for their difficulty and final boss HP.
|
# for their difficulty and final boss HP.
|
||||||
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
|
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
|
||||||
# The options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the relevant
|
# The options dataclass has a method to return a `dict[str, Any]` of each option name provided and the relevant
|
||||||
# option's value.
|
# option's value.
|
||||||
return self.options.as_dict("difficulty", "final_boss_hp")
|
return self.options.as_dict("difficulty", "final_boss_hp")
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -74,13 +74,12 @@ class EntranceLookup:
|
|||||||
if entrance in self._expands_graph_cache:
|
if entrance in self._expands_graph_cache:
|
||||||
return self._expands_graph_cache[entrance]
|
return self._expands_graph_cache[entrance]
|
||||||
|
|
||||||
visited = set()
|
seen = {entrance.connected_region}
|
||||||
q: deque[Region] = deque()
|
q: deque[Region] = deque()
|
||||||
q.append(entrance.connected_region)
|
q.append(entrance.connected_region)
|
||||||
|
|
||||||
while q:
|
while q:
|
||||||
region = q.popleft()
|
region = q.popleft()
|
||||||
visited.add(region)
|
|
||||||
|
|
||||||
# check if the region itself is progression
|
# check if the region itself is progression
|
||||||
if region in region.multiworld.indirect_connections:
|
if region in region.multiworld.indirect_connections:
|
||||||
@@ -103,7 +102,8 @@ class EntranceLookup:
|
|||||||
and exit_ in self._usable_exits):
|
and exit_ in self._usable_exits):
|
||||||
self._expands_graph_cache[entrance] = True
|
self._expands_graph_cache[entrance] = True
|
||||||
return True
|
return True
|
||||||
elif exit_.connected_region and exit_.connected_region not in visited:
|
elif exit_.connected_region and exit_.connected_region not in seen:
|
||||||
|
seen.add(exit_.connected_region)
|
||||||
q.append(exit_.connected_region)
|
q.append(exit_.connected_region)
|
||||||
|
|
||||||
self._expands_graph_cache[entrance] = False
|
self._expands_graph_cache[entrance] = False
|
||||||
|
|||||||
4
kvui.py
4
kvui.py
@@ -720,13 +720,11 @@ class MessageBoxLabel(MDLabel):
|
|||||||
|
|
||||||
|
|
||||||
class MessageBox(Popup):
|
class MessageBox(Popup):
|
||||||
|
|
||||||
def __init__(self, title, text, error=False, **kwargs):
|
def __init__(self, title, text, error=False, **kwargs):
|
||||||
label = MessageBoxLabel(text=text)
|
label = MessageBoxLabel(text=text, padding=("6dp", "0dp"))
|
||||||
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
||||||
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
|
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
|
||||||
separator_color=separator_color, **kwargs)
|
separator_color=separator_color, **kwargs)
|
||||||
self.height += max(0, label.height - 18)
|
|
||||||
|
|
||||||
|
|
||||||
class MDNavigationItemBase(MDNavigationItem):
|
class MDNavigationItemBase(MDNavigationItem):
|
||||||
|
|||||||
@@ -754,7 +754,12 @@ class Settings(Group):
|
|||||||
return super().__getattribute__(key)
|
return super().__getattribute__(key)
|
||||||
# directly import world and grab settings class
|
# directly import world and grab settings class
|
||||||
world_mod, world_cls_name = _world_settings_name_cache[key].rsplit(".", 1)
|
world_mod, world_cls_name = _world_settings_name_cache[key].rsplit(".", 1)
|
||||||
world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name))
|
try:
|
||||||
|
world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name))
|
||||||
|
except AttributeError:
|
||||||
|
import warnings
|
||||||
|
warnings.warn(f"World {world_cls_name} failed to initialize properly.")
|
||||||
|
return super().__getattribute__(key)
|
||||||
assert getattr(world, "settings_key") == key
|
assert getattr(world, "settings_key") == key
|
||||||
try:
|
try:
|
||||||
cls_or_name = world.__annotations__["settings"]
|
cls_or_name = world.__annotations__["settings"]
|
||||||
|
|||||||
24
setup.py
24
setup.py
@@ -22,7 +22,7 @@ SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fi
|
|||||||
|
|
||||||
|
|
||||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
requirement = 'cx-Freeze==8.0.0'
|
requirement = 'cx-Freeze==8.4.0'
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
try:
|
try:
|
||||||
@@ -30,7 +30,7 @@ try:
|
|||||||
install_cx_freeze = False
|
install_cx_freeze = False
|
||||||
except pkg_resources.ResolutionError:
|
except pkg_resources.ResolutionError:
|
||||||
install_cx_freeze = True
|
install_cx_freeze = True
|
||||||
except ImportError:
|
except (AttributeError, ImportError):
|
||||||
install_cx_freeze = True
|
install_cx_freeze = True
|
||||||
pkg_resources = None # type: ignore[assignment]
|
pkg_resources = None # type: ignore[assignment]
|
||||||
|
|
||||||
@@ -65,7 +65,6 @@ from Cython.Build import cythonize
|
|||||||
non_apworlds: set[str] = {
|
non_apworlds: set[str] = {
|
||||||
"A Link to the Past",
|
"A Link to the Past",
|
||||||
"Adventure",
|
"Adventure",
|
||||||
"ArchipIDLE",
|
|
||||||
"Archipelago",
|
"Archipelago",
|
||||||
"Lufia II Ancient Cave",
|
"Lufia II Ancient Cave",
|
||||||
"Meritous",
|
"Meritous",
|
||||||
@@ -372,6 +371,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
|
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
|
||||||
from Options import generate_yaml_templates
|
from Options import generate_yaml_templates
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from worlds.Files import APWorldContainer
|
||||||
|
from Utils import version
|
||||||
assert not non_apworlds - set(AutoWorldRegister.world_types), \
|
assert not non_apworlds - set(AutoWorldRegister.world_types), \
|
||||||
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
|
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
|
||||||
folders_to_remove: list[str] = []
|
folders_to_remove: list[str] = []
|
||||||
@@ -380,13 +381,26 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
if worldname not in non_apworlds:
|
if worldname not in non_apworlds:
|
||||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||||
world_directory = self.libfolder / "worlds" / file_name
|
world_directory = self.libfolder / "worlds" / file_name
|
||||||
|
if os.path.isfile(world_directory / "archipelago.json"):
|
||||||
|
manifest = json.load(open(world_directory / "archipelago.json"))
|
||||||
|
else:
|
||||||
|
manifest = {}
|
||||||
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
||||||
# which should be ok
|
# which should be ok
|
||||||
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
|
zip_path = self.libfolder / "worlds" / (file_name + ".apworld")
|
||||||
|
apworld = APWorldContainer(str(zip_path))
|
||||||
|
apworld.minimum_ap_version = version
|
||||||
|
apworld.maximum_ap_version = version
|
||||||
|
apworld.game = worldtype.game
|
||||||
|
manifest.update(apworld.get_manifest())
|
||||||
|
apworld.manifest_path = f"{file_name}/archipelago.json"
|
||||||
|
with zipfile.ZipFile(zip_path, "x", zipfile.ZIP_DEFLATED,
|
||||||
compresslevel=9) as zf:
|
compresslevel=9) as zf:
|
||||||
for path in world_directory.rglob("*.*"):
|
for path in world_directory.rglob("*.*"):
|
||||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
||||||
zf.write(path, relative_path)
|
if not relative_path.endswith("archipelago.json"):
|
||||||
|
zf.write(path, relative_path)
|
||||||
|
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
||||||
folders_to_remove.append(file_name)
|
folders_to_remove.append(file_name)
|
||||||
shutil.rmtree(world_directory)
|
shutil.rmtree(world_directory)
|
||||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .bases import TestBase, WorldTestBase
|
|
||||||
from warnings import warn
|
|
||||||
warn("TestBase was renamed to bases", DeprecationWarning)
|
|
||||||
@@ -9,98 +9,7 @@ from test.general import gen_steps
|
|||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.AutoWorld import World, call_all
|
from worlds.AutoWorld import World, call_all
|
||||||
|
|
||||||
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
|
from BaseClasses import Location, MultiWorld, CollectionState, Item
|
||||||
from worlds.alttp.Items import item_factory
|
|
||||||
|
|
||||||
|
|
||||||
class TestBase(unittest.TestCase):
|
|
||||||
multiworld: MultiWorld
|
|
||||||
_state_cache = {}
|
|
||||||
|
|
||||||
def get_state(self, items):
|
|
||||||
if (self.multiworld, tuple(items)) in self._state_cache:
|
|
||||||
return self._state_cache[self.multiworld, tuple(items)]
|
|
||||||
state = CollectionState(self.multiworld)
|
|
||||||
for item in items:
|
|
||||||
item.classification = ItemClassification.progression
|
|
||||||
state.collect(item, prevent_sweep=True)
|
|
||||||
state.sweep_for_advancements()
|
|
||||||
state.update_reachable_regions(1)
|
|
||||||
self._state_cache[self.multiworld, tuple(items)] = state
|
|
||||||
return state
|
|
||||||
|
|
||||||
def get_path(self, state, region):
|
|
||||||
def flist_to_iter(node):
|
|
||||||
while node:
|
|
||||||
value, node = node
|
|
||||||
yield value
|
|
||||||
|
|
||||||
from itertools import zip_longest
|
|
||||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
|
||||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
|
||||||
# Now we combine the flat string list into (region, exit) pairs
|
|
||||||
pathsiter = iter(string_path_flat)
|
|
||||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
|
||||||
return list(pathpairs)
|
|
||||||
|
|
||||||
def run_location_tests(self, access_pool):
|
|
||||||
for i, (location, access, *item_pool) in enumerate(access_pool):
|
|
||||||
items = item_pool[0]
|
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
|
||||||
state = self._get_items(item_pool, all_except)
|
|
||||||
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
|
||||||
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
|
||||||
all_except=all_except, path=path, entry=i):
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
|
|
||||||
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
|
||||||
|
|
||||||
# check for partial solution
|
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
|
||||||
for missing_item in item_pool[0]:
|
|
||||||
with self.subTest(msg="Location reachable without required item", location=location,
|
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
|
|
||||||
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
|
|
||||||
f"{missing_item} removed from: {item_pool}")
|
|
||||||
|
|
||||||
def run_entrance_tests(self, access_pool):
|
|
||||||
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
|
||||||
items = item_pool[0]
|
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
|
||||||
state = self._get_items(item_pool, all_except)
|
|
||||||
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
|
||||||
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
|
||||||
all_except=all_except, path=path, entry=i):
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
|
||||||
|
|
||||||
# check for partial solution
|
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
|
||||||
for missing_item in item_pool[0]:
|
|
||||||
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
|
|
||||||
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
|
|
||||||
|
|
||||||
def _get_items(self, item_pool, all_except):
|
|
||||||
if all_except and len(all_except) > 0:
|
|
||||||
items = self.multiworld.itempool[:]
|
|
||||||
items = [item for item in items if
|
|
||||||
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
|
||||||
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
|
|
||||||
else:
|
|
||||||
items = item_factory(item_pool[0], self.multiworld.worlds[1])
|
|
||||||
return self.get_state(items)
|
|
||||||
|
|
||||||
def _get_items_partial(self, item_pool, missing_item):
|
|
||||||
new_items = item_pool[0].copy()
|
|
||||||
new_items.remove(missing_item)
|
|
||||||
items = item_factory(new_items, self.multiworld.worlds[1])
|
|
||||||
return self.get_state(items)
|
|
||||||
|
|
||||||
|
|
||||||
class WorldTestBase(unittest.TestCase):
|
class WorldTestBase(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from typing import List, Optional, Tuple, Type, Union
|
from typing import Any, List, Optional, Tuple, Type
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.AutoWorld import World, call_all
|
from worlds.AutoWorld import World, WebWorld, call_all
|
||||||
|
|
||||||
gen_steps = (
|
gen_steps = (
|
||||||
"generate_early",
|
"generate_early",
|
||||||
@@ -17,7 +17,7 @@ gen_steps = (
|
|||||||
|
|
||||||
|
|
||||||
def setup_solo_multiworld(
|
def setup_solo_multiworld(
|
||||||
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
|
world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None
|
||||||
) -> MultiWorld:
|
) -> MultiWorld:
|
||||||
"""
|
"""
|
||||||
Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps.
|
Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps.
|
||||||
@@ -31,8 +31,8 @@ def setup_solo_multiworld(
|
|||||||
return setup_multiworld(world_type, steps, seed)
|
return setup_multiworld(world_type, steps, seed)
|
||||||
|
|
||||||
|
|
||||||
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
|
def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str, ...] = gen_steps,
|
||||||
seed: Optional[int] = None) -> MultiWorld:
|
seed: int | None = None, options: dict[str, Any] | list[dict[str, Any]] = None) -> MultiWorld:
|
||||||
"""
|
"""
|
||||||
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
|
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
|
||||||
calling the provided gen steps.
|
calling the provided gen steps.
|
||||||
@@ -40,20 +40,27 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
|
|||||||
:param worlds: Type/s of worlds to generate a multiworld for
|
:param worlds: Type/s of worlds to generate a multiworld for
|
||||||
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
|
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
|
||||||
:param seed: The seed to be used when creating this multiworld
|
:param seed: The seed to be used when creating this multiworld
|
||||||
|
:param options: Options to set on each world. If just one dict of options is passed, it will be used for all worlds.
|
||||||
:return: The generated multiworld
|
:return: The generated multiworld
|
||||||
"""
|
"""
|
||||||
if not isinstance(worlds, list):
|
if not isinstance(worlds, list):
|
||||||
worlds = [worlds]
|
worlds = [worlds]
|
||||||
|
|
||||||
|
if options is None:
|
||||||
|
options = [{}] * len(worlds)
|
||||||
|
elif not isinstance(options, list):
|
||||||
|
options = [options] * len(worlds)
|
||||||
|
|
||||||
players = len(worlds)
|
players = len(worlds)
|
||||||
multiworld = MultiWorld(players)
|
multiworld = MultiWorld(players)
|
||||||
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
||||||
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
||||||
multiworld.set_seed(seed)
|
multiworld.set_seed(seed)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for player, world_type in enumerate(worlds, 1):
|
for player, (world_type, option_overrides) in enumerate(zip(worlds, options), 1):
|
||||||
for key, option in world_type.options_dataclass.type_hints.items():
|
for key, option in world_type.options_dataclass.type_hints.items():
|
||||||
updated_options = getattr(args, key, {})
|
updated_options = getattr(args, key, {})
|
||||||
updated_options[player] = option.from_any(option.default)
|
updated_options[player] = option.from_any(option_overrides.get(key, option.default))
|
||||||
setattr(args, key, updated_options)
|
setattr(args, key, updated_options)
|
||||||
multiworld.set_options(args)
|
multiworld.set_options(args)
|
||||||
multiworld.state = CollectionState(multiworld)
|
multiworld.state = CollectionState(multiworld)
|
||||||
@@ -62,11 +69,16 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
|
|||||||
return multiworld
|
return multiworld
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebWorld(WebWorld):
|
||||||
|
tutorials = []
|
||||||
|
|
||||||
|
|
||||||
class TestWorld(World):
|
class TestWorld(World):
|
||||||
game = f"Test Game"
|
game = f"Test Game"
|
||||||
item_name_to_id = {}
|
item_name_to_id = {}
|
||||||
location_name_to_id = {}
|
location_name_to_id = {}
|
||||||
hidden = True
|
hidden = True
|
||||||
|
web = TestWebWorld()
|
||||||
|
|
||||||
|
|
||||||
# add our test world to the data package, so we can test it later
|
# add our test world to the data package, so we can test it later
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ class TestBase(unittest.TestCase):
|
|||||||
|
|
||||||
original_get_all_state = multiworld.get_all_state
|
original_get_all_state = multiworld.get_all_state
|
||||||
|
|
||||||
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||||
|
**kwargs):
|
||||||
self.assertTrue(allow_partial_entrances, (
|
self.assertTrue(allow_partial_entrances, (
|
||||||
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||||
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||||
))
|
))
|
||||||
|
|
||||||
return original_get_all_state(use_cache, allow_partial_entrances)
|
return original_get_all_state(use_cache, allow_partial_entrances, **kwargs)
|
||||||
|
|
||||||
multiworld.get_all_state = patched_get_all_state
|
multiworld.get_all_state = patched_get_all_state
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ class TestImplemented(unittest.TestCase):
|
|||||||
|
|
||||||
def test_slot_data(self):
|
def test_slot_data(self):
|
||||||
"""Tests that if a world creates slot data, it's json serializable."""
|
"""Tests that if a world creates slot data, it's json serializable."""
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
# has an await for generate_output which isn't being called
|
||||||
# has an await for generate_output which isn't being called
|
excluded_games = ("Ocarina of Time",)
|
||||||
if game_name in {"Ocarina of Time"}:
|
worlds_to_test = {game: world
|
||||||
continue
|
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||||
|
for game_name, world_type in worlds_to_test.items():
|
||||||
multiworld = setup_solo_multiworld(world_type)
|
multiworld = setup_solo_multiworld(world_type)
|
||||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||||
distribute_items_restrictive(multiworld)
|
distribute_items_restrictive(multiworld)
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ class TestBase(unittest.TestCase):
|
|||||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||||
gen_steps = ("generate_early",)
|
gen_steps = ("generate_early",)
|
||||||
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
for game_name, world_type in worlds_to_test.items():
|
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||||
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_location_creation_steps(self):
|
def test_location_creation_steps(self):
|
||||||
"""Tests that Regions and Locations aren't created after `create_items`."""
|
"""Tests that Regions and Locations aren't created after `create_items`."""
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
|
||||||
|
worlds_to_test = {game: world
|
||||||
|
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||||
|
for game_name, world_type in worlds_to_test.items():
|
||||||
with self.subTest("Game", game_name=game_name):
|
with self.subTest("Game", game_name=game_name):
|
||||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||||
region_count = len(multiworld.get_regions())
|
region_count = len(multiworld.get_regions())
|
||||||
@@ -54,13 +57,13 @@ class TestBase(unittest.TestCase):
|
|||||||
call_all(multiworld, "generate_basic")
|
call_all(multiworld, "generate_basic")
|
||||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
f"{game_name} modified region count during generate_basic")
|
f"{game_name} modified region count during generate_basic")
|
||||||
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
|
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||||
f"{game_name} modified locations count during generate_basic")
|
f"{game_name} modified locations count during generate_basic")
|
||||||
|
|
||||||
call_all(multiworld, "pre_fill")
|
call_all(multiworld, "pre_fill")
|
||||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
f"{game_name} modified region count during pre_fill")
|
f"{game_name} modified region count during pre_fill")
|
||||||
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
|
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||||
f"{game_name} modified locations count during pre_fill")
|
f"{game_name} modified locations count during pre_fill")
|
||||||
|
|
||||||
def test_location_group(self):
|
def test_location_group(self):
|
||||||
|
|||||||
63
test/webhost/test_sitemap.py
Normal file
63
test/webhost/test_sitemap.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import urllib.parse
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
import WebHost
|
||||||
|
from . import TestBase
|
||||||
|
|
||||||
|
|
||||||
|
class TestSitemap(TestBase):
|
||||||
|
|
||||||
|
# Codes for OK and some redirects that we use
|
||||||
|
valid_status_codes = [200, 302, 308]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
WebHost.copy_tutorials_files_to_static()
|
||||||
|
|
||||||
|
def test_sitemap_route(self) -> None:
|
||||||
|
"""Verify that the sitemap route works correctly and renders the template without errors."""
|
||||||
|
with self.app.test_request_context():
|
||||||
|
# Test the /sitemap route
|
||||||
|
with self.client.open("/sitemap") as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Site Map", response.data)
|
||||||
|
|
||||||
|
# Test the /index route which should also serve the sitemap
|
||||||
|
with self.client.open("/index") as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"Site Map", response.data)
|
||||||
|
|
||||||
|
# Test using url_for with the function name
|
||||||
|
with self.client.open(url_for('get_sitemap')) as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Site Map', response.data)
|
||||||
|
|
||||||
|
def test_sitemap_links(self) -> None:
|
||||||
|
"""
|
||||||
|
Verify that all links in the sitemap are valid by making a request to each one.
|
||||||
|
"""
|
||||||
|
with self.app.test_request_context():
|
||||||
|
with self.client.open(url_for("get_sitemap")) as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html_content = response.data.decode()
|
||||||
|
|
||||||
|
# Extract all href links using regex
|
||||||
|
href_pattern = re.compile(r'href=["\'](.*?)["\']')
|
||||||
|
links = href_pattern.findall(html_content)
|
||||||
|
|
||||||
|
self.assertTrue(len(links) > 0, "No links found in sitemap")
|
||||||
|
|
||||||
|
# Test each link
|
||||||
|
for link in links:
|
||||||
|
# Skip external links
|
||||||
|
if link.startswith(("http://", "https://")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
link = urllib.parse.unquote(html.unescape(link))
|
||||||
|
|
||||||
|
with self.client.open(link) as response, self.subTest(link=link):
|
||||||
|
self.assertIn(response.status_code, self.valid_status_codes,
|
||||||
|
f"Link {link} returned invalid status code {response.status_code}")
|
||||||
@@ -93,3 +93,13 @@ class TestTracker(TestBase):
|
|||||||
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
|
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_tracker_api(self) -> None:
|
||||||
|
"""Verify that tracker api gives a reply for the room."""
|
||||||
|
with self.app.test_request_context():
|
||||||
|
with self.client.open(url_for("api.tracker_data", tracker=self.tracker_uuid)) as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
with self.client.open(url_for("api.static_tracker_data", tracker=self.tracker_uuid)) as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
with self.client.open(url_for("api.tracker_slot_data", tracker=self.tracker_uuid)) as response:
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
def load_tests(loader, standard_tests, pattern):
|
from typing import TYPE_CHECKING
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from unittest import TestLoader, TestSuite
|
||||||
|
|
||||||
|
|
||||||
|
def load_tests(loader: "TestLoader", standard_tests: "TestSuite", pattern: str):
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
import fnmatch
|
||||||
from .. import file_path
|
from .. import file_path
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
suite = unittest.TestSuite()
|
suite = unittest.TestSuite()
|
||||||
suite.addTests(standard_tests)
|
suite.addTests(standard_tests)
|
||||||
|
|
||||||
|
# pattern hack
|
||||||
|
# all tests from within __init__ are always imported, so we need to filter out the folder earlier
|
||||||
|
# if the pattern isn't matching a specific world, we don't have much of a solution
|
||||||
|
|
||||||
|
if pattern.startswith("worlds."):
|
||||||
|
if pattern.endswith(".py"):
|
||||||
|
pattern = pattern[:-3]
|
||||||
|
components = pattern.split(".")
|
||||||
|
world_glob = f"worlds.{components[1]}"
|
||||||
|
pattern = components[-1]
|
||||||
|
|
||||||
|
elif pattern.startswith(f"worlds{os.path.sep}") or pattern.startswith(f"worlds{os.path.altsep}"):
|
||||||
|
components = pattern.split(os.path.sep)
|
||||||
|
if len(components) == 1:
|
||||||
|
components = pattern.split(os.path.altsep)
|
||||||
|
world_glob = f"worlds.{components[1]}"
|
||||||
|
pattern = components[-1]
|
||||||
|
else:
|
||||||
|
world_glob = "*"
|
||||||
|
|
||||||
|
|
||||||
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
|
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
|
||||||
for world in AutoWorldRegister.world_types.values()]
|
for world in AutoWorldRegister.world_types.values()
|
||||||
|
if fnmatch.fnmatch(world.__module__, world_glob)]
|
||||||
|
|
||||||
all_tests = [
|
all_tests = [
|
||||||
test_case for folder in folders if os.path.exists(folder)
|
test_case for folder in folders if os.path.exists(folder)
|
||||||
for test_collection in loader.discover(folder, top_level_dir=file_path)
|
for test_collection in loader.discover(folder, top_level_dir=file_path, pattern=pattern)
|
||||||
for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite)
|
for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite)
|
||||||
for test_case in test_suite
|
for test_case in test_suite
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
|
|||||||
|
|
||||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState
|
||||||
from Utils import deprecate
|
from Utils import Version
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||||
@@ -22,6 +22,10 @@ if TYPE_CHECKING:
|
|||||||
perf_logger = logging.getLogger("performance")
|
perf_logger = logging.getLogger("performance")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidItemError(KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AutoWorldRegister(type):
|
class AutoWorldRegister(type):
|
||||||
world_types: Dict[str, Type[World]] = {}
|
world_types: Dict[str, Type[World]] = {}
|
||||||
__file__: str
|
__file__: str
|
||||||
@@ -71,6 +75,10 @@ class AutoWorldRegister(type):
|
|||||||
if "required_client_version" in base.__dict__:
|
if "required_client_version" in base.__dict__:
|
||||||
dct["required_client_version"] = max(dct["required_client_version"],
|
dct["required_client_version"] = max(dct["required_client_version"],
|
||||||
base.__dict__["required_client_version"])
|
base.__dict__["required_client_version"])
|
||||||
|
if "world_version" in dct:
|
||||||
|
if dct["world_version"] != Version(0, 0, 0):
|
||||||
|
raise RuntimeError(f"{name} is attempting to set 'world_version' from within the class. world_version "
|
||||||
|
f"can only be set from manifest.")
|
||||||
|
|
||||||
# construct class
|
# construct class
|
||||||
new_class = super().__new__(mcs, name, bases, dct)
|
new_class = super().__new__(mcs, name, bases, dct)
|
||||||
@@ -333,6 +341,8 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
"""If loaded from a .apworld, this is the Path to it."""
|
"""If loaded from a .apworld, this is the Path to it."""
|
||||||
__file__: ClassVar[str]
|
__file__: ClassVar[str]
|
||||||
"""path it was loaded from"""
|
"""path it was loaded from"""
|
||||||
|
world_version: ClassVar[Version] = Version(0, 0, 0)
|
||||||
|
"""Optional world version loaded from archipelago.json"""
|
||||||
|
|
||||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||||
assert multiworld is not None
|
assert multiworld is not None
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
|
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence,
|
||||||
|
TYPE_CHECKING)
|
||||||
|
|
||||||
import bsdiff4
|
import bsdiff4
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ semaphore = threading.Semaphore(os.cpu_count() or 4)
|
|||||||
|
|
||||||
del threading
|
del threading
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from Utils import Version
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(abc.ABCMeta):
|
class AutoPatchRegister(abc.ABCMeta):
|
||||||
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||||
@@ -65,7 +69,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
|
|||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
container_version: int = 6
|
container_version: int = 7
|
||||||
|
|
||||||
|
|
||||||
def is_ap_player_container(game: str, data: bytes, player: int):
|
def is_ap_player_container(game: str, data: bytes, player: int):
|
||||||
@@ -92,7 +96,7 @@ class APContainer:
|
|||||||
version: ClassVar[int] = container_version
|
version: ClassVar[int] = container_version
|
||||||
compression_level: ClassVar[int] = 9
|
compression_level: ClassVar[int] = 9
|
||||||
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
|
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
|
||||||
|
manifest_path: str = "archipelago.json"
|
||||||
path: Optional[str]
|
path: Optional[str]
|
||||||
|
|
||||||
def __init__(self, path: Optional[str] = None):
|
def __init__(self, path: Optional[str] = None):
|
||||||
@@ -116,7 +120,7 @@ class APContainer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||||
else:
|
else:
|
||||||
opened_zipfile.writestr("archipelago.json", manifest_str)
|
opened_zipfile.writestr(self.manifest_path, manifest_str)
|
||||||
|
|
||||||
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
||||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||||
@@ -137,7 +141,18 @@ class APContainer:
|
|||||||
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
|
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
try:
|
||||||
|
assert self.manifest_path.endswith("archipelago.json"), "Filename should be archipelago.json"
|
||||||
|
manifest_info = opened_zipfile.getinfo(self.manifest_path)
|
||||||
|
except KeyError as e:
|
||||||
|
for info in opened_zipfile.infolist():
|
||||||
|
if info.filename.endswith("archipelago.json"):
|
||||||
|
manifest_info = info
|
||||||
|
self.manifest_path = info.filename
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
with opened_zipfile.open(manifest_info, "r") as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
if manifest["compatible_version"] > self.version:
|
if manifest["compatible_version"] > self.version:
|
||||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||||
@@ -152,6 +167,33 @@ class APContainer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class APWorldContainer(APContainer):
|
||||||
|
"""A zipfile containing a world implementation."""
|
||||||
|
game: str | None = None
|
||||||
|
world_version: "Version | None" = None
|
||||||
|
minimum_ap_version: "Version | None" = None
|
||||||
|
maximum_ap_version: "Version | None" = None
|
||||||
|
|
||||||
|
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||||
|
from Utils import tuplize_version, Version
|
||||||
|
manifest = super().read_contents(opened_zipfile)
|
||||||
|
self.game = manifest["game"]
|
||||||
|
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
|
||||||
|
if version_key in manifest:
|
||||||
|
setattr(self, version_key, Version(*tuplize_version(manifest[version_key])))
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def get_manifest(self) -> Dict[str, Any]:
|
||||||
|
manifest = super().get_manifest()
|
||||||
|
manifest["game"] = self.game
|
||||||
|
manifest["compatible_version"] = 7
|
||||||
|
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
|
||||||
|
version = getattr(self, version_key)
|
||||||
|
if version:
|
||||||
|
manifest[version_key] = version.as_simple_string()
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
class APPlayerContainer(APContainer):
|
class APPlayerContainer(APContainer):
|
||||||
"""A zipfile containing at least archipelago.json meant for a player"""
|
"""A zipfile containing at least archipelago.json meant for a player"""
|
||||||
game: ClassVar[Optional[str]] = None
|
game: ClassVar[Optional[str]] = None
|
||||||
@@ -248,10 +290,8 @@ class APProcedurePatch(APAutoPatchInterface):
|
|||||||
manifest["compatible_version"] = 5
|
manifest["compatible_version"] = 5
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||||
super(APProcedurePatch, self).read_contents(opened_zipfile)
|
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile)
|
||||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
if "procedure" not in manifest:
|
if "procedure" not in manifest:
|
||||||
# support patching files made before moving to procedures
|
# support patching files made before moving to procedures
|
||||||
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
|
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
|
||||||
@@ -260,6 +300,7 @@ class APProcedurePatch(APAutoPatchInterface):
|
|||||||
for file in opened_zipfile.namelist():
|
for file in opened_zipfile.namelist():
|
||||||
if file not in ["archipelago.json"]:
|
if file not in ["archipelago.json"]:
|
||||||
self.files[file] = opened_zipfile.read(file)
|
self.files[file] = opened_zipfile.read(file)
|
||||||
|
return manifest
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||||
super(APProcedurePatch, self).write_contents(opened_zipfile)
|
super(APProcedurePatch, self).write_contents(opened_zipfile)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import weakref
|
|||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Optional, Callable, List, Iterable, Tuple
|
from typing import Optional, Callable, List, Iterable, Tuple
|
||||||
|
|
||||||
from Utils import local_path, open_filename
|
from Utils import local_path, open_filename, is_frozen, is_kivy_running
|
||||||
|
|
||||||
|
|
||||||
class Type(Enum):
|
class Type(Enum):
|
||||||
@@ -177,10 +177,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
|||||||
if module_name == loaded_name:
|
if module_name == loaded_name:
|
||||||
found_already_loaded = True
|
found_already_loaded = True
|
||||||
break
|
break
|
||||||
if found_already_loaded:
|
if found_already_loaded and is_kivy_running():
|
||||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, "
|
||||||
"so a Launcher restart is required to use the new installation.\n"
|
"so a Launcher restart is required to use the new installation.")
|
||||||
"If the Launcher is not open, no action needs to be taken.")
|
|
||||||
world_source = worlds.WorldSource(str(target), is_zip=True)
|
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||||
bisect.insort(worlds.world_sources, world_source)
|
bisect.insort(worlds.world_sources, world_source)
|
||||||
world_source.load()
|
world_source.load()
|
||||||
@@ -197,7 +196,7 @@ def install_apworld(apworld_path: str = "") -> None:
|
|||||||
source, target = res
|
source, target = res
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import Utils
|
import Utils
|
||||||
Utils.messagebox(e.__class__.__name__, str(e), error=True)
|
Utils.messagebox("Notice", str(e), error=True)
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
else:
|
else:
|
||||||
import Utils
|
import Utils
|
||||||
@@ -229,8 +228,6 @@ components: List[Component] = [
|
|||||||
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
|
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||||
# Starcraft 2
|
|
||||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
|
||||||
# Zillion
|
# Zillion
|
||||||
Component('Zillion Client', 'ZillionClient',
|
Component('Zillion Client', 'ZillionClient',
|
||||||
file_identifier=SuffixIdentifier('.apzl')),
|
file_identifier=SuffixIdentifier('.apzl')),
|
||||||
@@ -245,3 +242,39 @@ icon_paths = {
|
|||||||
'icon': local_path('data', 'icon.png'),
|
'icon': local_path('data', 'icon.png'),
|
||||||
'discord': local_path('data', 'discord-mark-blue.png'),
|
'discord': local_path('data', 'discord-mark-blue.png'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not is_frozen():
|
||||||
|
def _build_apworlds():
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
from worlds.Files import APWorldContainer
|
||||||
|
|
||||||
|
apworlds_folder = os.path.join("build", "apworlds")
|
||||||
|
os.makedirs(apworlds_folder, exist_ok=True)
|
||||||
|
for worldname, worldtype in AutoWorldRegister.world_types.items():
|
||||||
|
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||||
|
world_directory = os.path.join("worlds", file_name)
|
||||||
|
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
|
||||||
|
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
|
||||||
|
else:
|
||||||
|
manifest = {}
|
||||||
|
|
||||||
|
zip_path = os.path.join(apworlds_folder, file_name + ".apworld")
|
||||||
|
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)
|
||||||
|
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
||||||
|
|
||||||
|
components.append(Component('Build apworlds', func=_build_apworlds, cli=True,))
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import warnings
|
|||||||
import zipimport
|
import zipimport
|
||||||
import time
|
import time
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import json
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from NetUtils import DataPackage
|
from NetUtils import DataPackage
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
|
||||||
|
|
||||||
local_folder = os.path.dirname(__file__)
|
local_folder = os.path.dirname(__file__)
|
||||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
||||||
@@ -38,6 +39,7 @@ class WorldSource:
|
|||||||
is_zip: bool = False
|
is_zip: bool = False
|
||||||
relative: bool = True # relative to regular world import folder
|
relative: bool = True # relative to regular world import folder
|
||||||
time_taken: float = -1.0
|
time_taken: float = -1.0
|
||||||
|
version: Version = Version(0, 0, 0)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
||||||
@@ -102,12 +104,94 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
|
|||||||
|
|
||||||
# import all submodules to trigger AutoWorldRegister
|
# import all submodules to trigger AutoWorldRegister
|
||||||
world_sources.sort()
|
world_sources.sort()
|
||||||
|
apworlds: list[WorldSource] = []
|
||||||
for world_source in world_sources:
|
for world_source in world_sources:
|
||||||
world_source.load()
|
# load all loose files first:
|
||||||
|
if world_source.is_zip:
|
||||||
|
apworlds.append(world_source)
|
||||||
|
else:
|
||||||
|
world_source.load()
|
||||||
|
|
||||||
|
|
||||||
# Build the data package for each game.
|
|
||||||
from .AutoWorld import AutoWorldRegister
|
from .AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
|
for world_source in world_sources:
|
||||||
|
if not world_source.is_zip:
|
||||||
|
# look for manifest
|
||||||
|
manifest = {}
|
||||||
|
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
|
||||||
|
for file in filenames:
|
||||||
|
if file.endswith("archipelago.json"):
|
||||||
|
manifest = json.load(open(os.path.join(dirpath, file), "r"))
|
||||||
|
break
|
||||||
|
if manifest:
|
||||||
|
break
|
||||||
|
game = manifest.get("game")
|
||||||
|
if game in AutoWorldRegister.world_types:
|
||||||
|
AutoWorldRegister.world_types[game].world_version = Version(*tuplize_version(manifest.get("world_version",
|
||||||
|
"0.0.0")))
|
||||||
|
|
||||||
|
if apworlds:
|
||||||
|
# encapsulation for namespace / gc purposes
|
||||||
|
def load_apworlds() -> None:
|
||||||
|
global apworlds
|
||||||
|
from .Files import APWorldContainer, InvalidDataError
|
||||||
|
core_compatible: list[tuple[WorldSource, APWorldContainer]] = []
|
||||||
|
|
||||||
|
def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True) -> None:
|
||||||
|
if add_as_failed_to_load:
|
||||||
|
failed_world_loads.append(game_name)
|
||||||
|
logging.warning(reason)
|
||||||
|
|
||||||
|
for apworld_source in apworlds:
|
||||||
|
apworld: APWorldContainer = APWorldContainer(apworld_source.resolved_path)
|
||||||
|
# populate metadata
|
||||||
|
try:
|
||||||
|
apworld.read()
|
||||||
|
except InvalidDataError as e:
|
||||||
|
if version_tuple < (0, 7, 0):
|
||||||
|
logging.error(
|
||||||
|
f"Invalid or missing manifest file for {apworld_source.resolved_path}. "
|
||||||
|
"This apworld will stop working with Archipelago 0.7.0."
|
||||||
|
)
|
||||||
|
logging.error(e)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
|
||||||
|
fail_world(apworld.game,
|
||||||
|
f"Did not load {apworld_source.path} "
|
||||||
|
f"as its minimum core version {apworld.minimum_ap_version} "
|
||||||
|
f"is higher than current core version {version_tuple}.")
|
||||||
|
elif apworld.maximum_ap_version and apworld.maximum_ap_version < version_tuple:
|
||||||
|
fail_world(apworld.game,
|
||||||
|
f"Did not load {apworld_source.path} "
|
||||||
|
f"as its maximum core version {apworld.maximum_ap_version} "
|
||||||
|
f"is lower than current core version {version_tuple}.")
|
||||||
|
else:
|
||||||
|
core_compatible.append((apworld_source, apworld))
|
||||||
|
# load highest version first
|
||||||
|
core_compatible.sort(
|
||||||
|
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
|
||||||
|
reverse=True)
|
||||||
|
for apworld_source, apworld in core_compatible:
|
||||||
|
if apworld.game and apworld.game in AutoWorldRegister.world_types:
|
||||||
|
fail_world(apworld.game,
|
||||||
|
f"Did not load {apworld_source.path} "
|
||||||
|
f"as its game {apworld.game} is already loaded.",
|
||||||
|
add_as_failed_to_load=False)
|
||||||
|
else:
|
||||||
|
apworld_source.load()
|
||||||
|
if apworld.game in AutoWorldRegister.world_types:
|
||||||
|
# world could fail to load at this point
|
||||||
|
if apworld.world_version:
|
||||||
|
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
|
||||||
|
load_apworlds()
|
||||||
|
del load_apworlds
|
||||||
|
|
||||||
|
del apworlds
|
||||||
|
|
||||||
|
# Build the data package for each game.
|
||||||
network_data_package: DataPackage = {
|
network_data_package: DataPackage = {
|
||||||
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GameData:
|
|||||||
"""
|
"""
|
||||||
:param data:
|
:param data:
|
||||||
"""
|
"""
|
||||||
self.abilities: Dict[int, AbilityData] = {}
|
self.abilities: Dict[int, AbilityData] = {a.ability_id: AbilityData(self, a) for a in data.abilities if a.available}
|
||||||
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
|
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
|
||||||
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
|
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
|
||||||
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
|
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
|
||||||
@@ -40,7 +40,7 @@ class AbilityData:
|
|||||||
self._proto = proto
|
self._proto = proto
|
||||||
|
|
||||||
# What happens if we comment this out? Should this not be commented out? What is its purpose?
|
# What happens if we comment this out? Should this not be commented out? What is its purpose?
|
||||||
assert self.id != 0
|
# assert self.id != 0 # let the world burn
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"AbilityData(name={self._proto.button_name})"
|
return f"AbilityData(name={self._proto.button_name})"
|
||||||
|
|||||||
@@ -623,6 +623,23 @@ class ParadeTrapWeight(Range):
|
|||||||
default = 20
|
default = 20
|
||||||
|
|
||||||
|
|
||||||
|
class DeathLinkAmnesty(Range):
|
||||||
|
"""Amount of forgiven deaths before sending a Death Link.
|
||||||
|
0 means that every death will send a Death Link."""
|
||||||
|
display_name = "Death Link Amnesty"
|
||||||
|
range_start = 0
|
||||||
|
range_end = 20
|
||||||
|
default = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DWDeathLinkAmnesty(Range):
|
||||||
|
"""Amount of forgiven deaths before sending a Death Link during Death Wish levels."""
|
||||||
|
display_name = "Death Wish Amnesty"
|
||||||
|
range_start = 0
|
||||||
|
range_end = 30
|
||||||
|
default = 5
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AHITOptions(PerGameCommonOptions):
|
class AHITOptions(PerGameCommonOptions):
|
||||||
start_inventory_from_pool: StartInventoryPool
|
start_inventory_from_pool: StartInventoryPool
|
||||||
@@ -700,6 +717,8 @@ class AHITOptions(PerGameCommonOptions):
|
|||||||
ParadeTrapWeight: ParadeTrapWeight
|
ParadeTrapWeight: ParadeTrapWeight
|
||||||
|
|
||||||
death_link: DeathLink
|
death_link: DeathLink
|
||||||
|
death_link_amnesty: DeathLinkAmnesty
|
||||||
|
dw_death_link_amnesty: DWDeathLinkAmnesty
|
||||||
|
|
||||||
|
|
||||||
ahit_option_groups: Dict[str, List[Any]] = {
|
ahit_option_groups: Dict[str, List[Any]] = {
|
||||||
@@ -769,4 +788,6 @@ slot_data_options: List[str] = [
|
|||||||
"MaxPonCost",
|
"MaxPonCost",
|
||||||
|
|
||||||
"death_link",
|
"death_link",
|
||||||
|
"death_link_amnesty",
|
||||||
|
"dw_death_link_amnesty",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, CollectionState
|
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
|
|
||||||
|
|
||||||
class LTTPTestBase(unittest.TestCase):
|
|
||||||
def world_setup(self):
|
|
||||||
from worlds.alttp.Options import Medallion
|
|
||||||
self.multiworld = MultiWorld(1)
|
|
||||||
self.multiworld.game[1] = "A Link to the Past"
|
|
||||||
self.multiworld.set_seed(None)
|
|
||||||
args = Namespace()
|
|
||||||
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
|
|
||||||
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
|
|
||||||
self.multiworld.set_options(args)
|
|
||||||
self.multiworld.state = CollectionState(self.multiworld)
|
|
||||||
self.world = self.multiworld.worlds[1]
|
|
||||||
# by default medallion access is randomized, for unittests we set it to vanilla
|
|
||||||
self.world.options.misery_mire_medallion.value = Medallion.option_ether
|
|
||||||
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
|
|
||||||
|
|||||||
113
worlds/alttp/test/bases.py
Normal file
113
worlds/alttp/test/bases.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import unittest
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
from BaseClasses import MultiWorld, CollectionState, ItemClassification
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
from ..Items import item_factory
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(unittest.TestCase):
|
||||||
|
multiworld: MultiWorld
|
||||||
|
_state_cache = {}
|
||||||
|
|
||||||
|
def get_state(self, items):
|
||||||
|
if (self.multiworld, tuple(items)) in self._state_cache:
|
||||||
|
return self._state_cache[self.multiworld, tuple(items)]
|
||||||
|
state = CollectionState(self.multiworld)
|
||||||
|
for item in items:
|
||||||
|
item.classification = ItemClassification.progression
|
||||||
|
state.collect(item, prevent_sweep=True)
|
||||||
|
state.sweep_for_advancements()
|
||||||
|
state.update_reachable_regions(1)
|
||||||
|
self._state_cache[self.multiworld, tuple(items)] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_path(self, state, region):
|
||||||
|
def flist_to_iter(node):
|
||||||
|
while node:
|
||||||
|
value, node = node
|
||||||
|
yield value
|
||||||
|
|
||||||
|
from itertools import zip_longest
|
||||||
|
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||||
|
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||||
|
# Now we combine the flat string list into (region, exit) pairs
|
||||||
|
pathsiter = iter(string_path_flat)
|
||||||
|
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||||
|
return list(pathpairs)
|
||||||
|
|
||||||
|
def run_location_tests(self, access_pool):
|
||||||
|
for i, (location, access, *item_pool) in enumerate(access_pool):
|
||||||
|
items = item_pool[0]
|
||||||
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
|
state = self._get_items(item_pool, all_except)
|
||||||
|
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
||||||
|
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
||||||
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
|
||||||
|
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||||
|
|
||||||
|
# check for partial solution
|
||||||
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
|
for missing_item in item_pool[0]:
|
||||||
|
with self.subTest(msg="Location reachable without required item", location=location,
|
||||||
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
|
||||||
|
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
|
||||||
|
f"{missing_item} removed from: {item_pool}")
|
||||||
|
|
||||||
|
def run_entrance_tests(self, access_pool):
|
||||||
|
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
||||||
|
items = item_pool[0]
|
||||||
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
|
state = self._get_items(item_pool, all_except)
|
||||||
|
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
||||||
|
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
||||||
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
||||||
|
|
||||||
|
# check for partial solution
|
||||||
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
|
for missing_item in item_pool[0]:
|
||||||
|
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
||||||
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
|
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
|
||||||
|
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
|
||||||
|
|
||||||
|
def _get_items(self, item_pool, all_except):
|
||||||
|
if all_except and len(all_except) > 0:
|
||||||
|
items = self.multiworld.itempool[:]
|
||||||
|
items = [item for item in items if
|
||||||
|
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
|
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
|
||||||
|
else:
|
||||||
|
items = item_factory(item_pool[0], self.multiworld.worlds[1])
|
||||||
|
return self.get_state(items)
|
||||||
|
|
||||||
|
def _get_items_partial(self, item_pool, missing_item):
|
||||||
|
new_items = item_pool[0].copy()
|
||||||
|
new_items.remove(missing_item)
|
||||||
|
items = item_factory(new_items, self.multiworld.worlds[1])
|
||||||
|
return self.get_state(items)
|
||||||
|
|
||||||
|
|
||||||
|
class LTTPTestBase(unittest.TestCase):
|
||||||
|
def world_setup(self):
|
||||||
|
from worlds.alttp.Options import Medallion
|
||||||
|
self.multiworld = MultiWorld(1)
|
||||||
|
self.multiworld.game[1] = "A Link to the Past"
|
||||||
|
self.multiworld.set_seed(None)
|
||||||
|
args = Namespace()
|
||||||
|
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
|
||||||
|
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
|
||||||
|
self.multiworld.set_options(args)
|
||||||
|
self.multiworld.state = CollectionState(self.multiworld)
|
||||||
|
self.world = self.multiworld.worlds[1]
|
||||||
|
# by default medallion access is randomized, for unittests we set it to vanilla
|
||||||
|
self.world.options.misery_mire_medallion.value = Medallion.option_ether
|
||||||
|
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
|
||||||
@@ -5,7 +5,7 @@ from worlds.alttp.ItemPool import difficulties
|
|||||||
from worlds.alttp.Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from worlds.alttp.Regions import create_regions
|
from worlds.alttp.Regions import create_regions
|
||||||
from worlds.alttp.Shops import create_shops
|
from worlds.alttp.Shops import create_shops
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from worlds.alttp.test.bases import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestDungeon(LTTPTestBase):
|
class TestDungeon(LTTPTestBase):
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
from ...EntranceShuffle import link_inverted_entrances
|
||||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
from ...InvertedRegions import create_inverted_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from worlds.alttp.Regions import mark_light_world_regions
|
from ...Regions import mark_light_world_regions
|
||||||
from worlds.alttp.Shops import create_shops
|
from ...Shops import create_shops
|
||||||
from test.bases import TestBase
|
|
||||||
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInverted(TestBase, LTTPTestBase):
|
class TestInverted(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances
|
|||||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from worlds.alttp.Rules import set_inverted_big_bomb_rules
|
from worlds.alttp.Rules import set_inverted_big_bomb_rules
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from worlds.alttp.test.bases import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedBombRules(LTTPTestBase):
|
class TestInvertedBombRules(LTTPTestBase):
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
from ...EntranceShuffle import link_inverted_entrances
|
||||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
from ...InvertedRegions import create_inverted_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from worlds.alttp.Options import GlitchesRequired
|
from ...Options import GlitchesRequired
|
||||||
from worlds.alttp.Regions import mark_light_world_regions
|
from ...Regions import mark_light_world_regions
|
||||||
from worlds.alttp.Shops import create_shops
|
from ...Shops import create_shops
|
||||||
from test.bases import TestBase
|
|
||||||
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedMinor(TestBase, LTTPTestBase):
|
class TestInvertedMinor(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
from ...EntranceShuffle import link_inverted_entrances
|
||||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
from ...InvertedRegions import create_inverted_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from worlds.alttp.Options import GlitchesRequired
|
from ...Options import GlitchesRequired
|
||||||
from worlds.alttp.Regions import mark_light_world_regions
|
from ...Regions import mark_light_world_regions
|
||||||
from worlds.alttp.Shops import create_shops
|
from ...Shops import create_shops
|
||||||
from test.bases import TestBase
|
|
||||||
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedOWG(TestBase, LTTPTestBase):
|
class TestInvertedOWG(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from test.bases import TestBase
|
from ..bases import TestBase
|
||||||
|
|
||||||
base_items = 41
|
base_items = 41
|
||||||
extra_counts = (15, 15, 10, 5, 25)
|
extra_counts = (15, 15, 10, 5, 25)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
from ...InvertedRegions import mark_dark_world_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from test.bases import TestBase
|
from ...Options import GlitchesRequired
|
||||||
from worlds.alttp.Options import GlitchesRequired
|
|
||||||
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestMinor(TestBase, LTTPTestBase):
|
class TestMinor(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
from ...InvertedRegions import mark_dark_world_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from test.bases import TestBase
|
from ...Options import GlitchesRequired
|
||||||
from worlds.alttp.Options import GlitchesRequired
|
|
||||||
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestVanillaOWG(TestBase, LTTPTestBase):
|
class TestVanillaOWG(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from worlds.alttp.Shops import shop_table
|
from ...Shops import shop_table
|
||||||
from test.bases import TestBase
|
from ..bases import TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestSram(TestBase):
|
class TestSram(TestBase):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
from ...Dungeons import get_dungeon_item_pool
|
||||||
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
from ...InvertedRegions import mark_dark_world_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from ...ItemPool import difficulties
|
||||||
from worlds.alttp.Items import item_factory
|
from ...Items import item_factory
|
||||||
from test.bases import TestBase
|
from ...Options import GlitchesRequired
|
||||||
from worlds.alttp.Options import GlitchesRequired
|
|
||||||
from worlds.alttp.test import LTTPTestBase
|
from ..bases import LTTPTestBase, TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestVanilla(TestBase, LTTPTestBase):
|
class TestVanilla(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
item_table = (
|
|
||||||
'An Old GeoCities Profile',
|
|
||||||
'Very Funny Joke',
|
|
||||||
'Motivational Video',
|
|
||||||
'Staples Easy Button',
|
|
||||||
'One Million Dollars',
|
|
||||||
'Replica Master Sword',
|
|
||||||
'VHS Copy of Jurassic Park',
|
|
||||||
'32GB USB Drive',
|
|
||||||
'Pocket Protector',
|
|
||||||
'Leftover Parts from IKEA Furniture',
|
|
||||||
'Half-Empty Ink Cartridge for a Printer',
|
|
||||||
'Watch Battery',
|
|
||||||
'Towel',
|
|
||||||
'Scarf',
|
|
||||||
'2012 Magic the Gathering Core Set Starter Box',
|
|
||||||
'Poke\'mon Booster Pack',
|
|
||||||
'USB Speakers',
|
|
||||||
'Eco-Friendly Spork',
|
|
||||||
'Cheeseburger',
|
|
||||||
'Brand New Car',
|
|
||||||
'Hunting Knife',
|
|
||||||
'Zippo Lighter',
|
|
||||||
'Red Shirt',
|
|
||||||
'One-Up Mushroom',
|
|
||||||
'Nokia N-GAGE',
|
|
||||||
'2-Liter of Sprite',
|
|
||||||
'Free trial of the critically acclaimed MMORPG Final Fantasy XIV, including the entirety of A Realm Reborn and the award winning Heavensward and Stormblood expansions up to level 70 with no restrictions on playtime!',
|
|
||||||
'Can of Compressed Air',
|
|
||||||
'Striped Kitten',
|
|
||||||
'USB Power Adapter',
|
|
||||||
'Fortune Cookie',
|
|
||||||
'Nintendo Power Glove',
|
|
||||||
'The Lampshade of No Real Significance',
|
|
||||||
'Kneepads of Allure',
|
|
||||||
'Get Out of Jail Free Card',
|
|
||||||
'Box Set of Stargate SG-1 Season 4',
|
|
||||||
'The Missing Left Sock',
|
|
||||||
'Poster Tube',
|
|
||||||
'Electronic Picture Frame',
|
|
||||||
'Bottle of Shampoo',
|
|
||||||
'Your Mission, Should You Choose To Accept It',
|
|
||||||
'Fanny Pack',
|
|
||||||
'Robocop T-Shirt',
|
|
||||||
'Suspiciously Small Monocle',
|
|
||||||
'Table Saw',
|
|
||||||
'Cookies and Cream Milkshake',
|
|
||||||
'Deflated Accordion',
|
|
||||||
'Grandma\'s Homemade Pie',
|
|
||||||
'Invisible Lego on the Floor',
|
|
||||||
'Pitfall Trap',
|
|
||||||
'Flathead Screwdriver',
|
|
||||||
'Leftover Pizza',
|
|
||||||
'Voodoo Doll that Looks Like You',
|
|
||||||
'Pink Shoelaces',
|
|
||||||
'Half a Bottle of Scotch',
|
|
||||||
'Reminder Not to Forget Aginah',
|
|
||||||
'Medicine Ball',
|
|
||||||
'Yoga Mat',
|
|
||||||
'Chocolate Orange',
|
|
||||||
'Old Concert Tickets',
|
|
||||||
'The Pick of Destiny',
|
|
||||||
'McGuffin',
|
|
||||||
'Just a Regular McMuffin',
|
|
||||||
'34 Tacos',
|
|
||||||
'Duct Tape',
|
|
||||||
'Copy of Untitled Goose Game',
|
|
||||||
'Partially Used Bed Bath & Beyond Gift Card',
|
|
||||||
'Mostly Popped Bubble Wrap',
|
|
||||||
'Expired Driver\'s License',
|
|
||||||
'The Look, You Know the One',
|
|
||||||
'Transformers Lunch Box',
|
|
||||||
'MP3 Player',
|
|
||||||
'Dry Sharpie',
|
|
||||||
'Chalkboard Eraser',
|
|
||||||
'Overhead Projector',
|
|
||||||
'Physical Copy of the Japanese 1.0 Link to the Past',
|
|
||||||
'Collectable Action Figure',
|
|
||||||
'Box Set of The Lord of the Rings Books',
|
|
||||||
'Lite-Bright',
|
|
||||||
'Stories from the Good-Old-Days',
|
|
||||||
'Un-Reproducable Bug Reports',
|
|
||||||
'Autographed Copy of Shaq-Fu',
|
|
||||||
'Game-Winning Baseball',
|
|
||||||
'Portable Battery Bank',
|
|
||||||
'Blockbuster Membership Card',
|
|
||||||
'Offensive Bumper Sticker',
|
|
||||||
'Last Sunday\'s Crossword Puzzle',
|
|
||||||
'Rubik\'s Cube',
|
|
||||||
'Your First Grey Hair',
|
|
||||||
'Embarrassing Childhood Photo',
|
|
||||||
'Abandoned Sphere One Check',
|
|
||||||
'The Internet',
|
|
||||||
'Late-Night Cartoons',
|
|
||||||
'The Correct Usage of a Semicolon',
|
|
||||||
'Microsoft Windows 95 Resource Kit',
|
|
||||||
'Car-Phone',
|
|
||||||
'Walkman Radio',
|
|
||||||
'Relevant XKCD Comic',
|
|
||||||
'Razor Scooter',
|
|
||||||
'Set of Beyblades',
|
|
||||||
'Box of Pogs',
|
|
||||||
'Beanie-Baby Collection',
|
|
||||||
'Laser Tag Gun',
|
|
||||||
'Radio Controlled Car',
|
|
||||||
'Boogie Board',
|
|
||||||
'Air Jordans',
|
|
||||||
'Rubber Duckie',
|
|
||||||
'The Last Cookie in the Cookie Jar',
|
|
||||||
'Tin-Foil Hat',
|
|
||||||
'Button-Up Shirt',
|
|
||||||
'Designer Brand Bag',
|
|
||||||
'Trapper Keeper',
|
|
||||||
'Fake Moustache',
|
|
||||||
'Colored Pencils',
|
|
||||||
'Pair of 3D Glasses',
|
|
||||||
'Pair of Movie Tickets',
|
|
||||||
'Refrigerator Magnets',
|
|
||||||
'NASCAR Dinner Plates',
|
|
||||||
'The Final Boss',
|
|
||||||
'Unskippable Cutscenes',
|
|
||||||
'24 Rolls of Toilet Paper',
|
|
||||||
'Canned Soup',
|
|
||||||
'Warm Blanket',
|
|
||||||
'3D Printer',
|
|
||||||
'Jetpack',
|
|
||||||
'Hoverboard',
|
|
||||||
'Joycons with No Drift',
|
|
||||||
'Double Rainbow',
|
|
||||||
'Ping Pong Ball',
|
|
||||||
'Area 51 Arcade Cabinet',
|
|
||||||
'Elephant in the Room',
|
|
||||||
'The Pink Panther',
|
|
||||||
'Denim Shorts',
|
|
||||||
'Tennis Racket',
|
|
||||||
'Collection of Stuffed Animals',
|
|
||||||
'Old Cell Phone',
|
|
||||||
'Nintendo Virtual Boy',
|
|
||||||
'Box of 5.25 Inch Floppy Disks',
|
|
||||||
'Bag of Miscellaneous Wires',
|
|
||||||
'Garden Shovel',
|
|
||||||
'Leather Gloves',
|
|
||||||
'Knife of +9 VS Ogres',
|
|
||||||
'Old, Smelly Cheese',
|
|
||||||
'Linksys BEFSR41 Router',
|
|
||||||
'Ethernet Cables for a LAN Party',
|
|
||||||
'Mechanical Pencil',
|
|
||||||
'Book of Graph Paper',
|
|
||||||
'300 Sheets of Printer Paper',
|
|
||||||
'One AAA Battery',
|
|
||||||
'Box of Old Game Controllers',
|
|
||||||
'Sega Dreamcast',
|
|
||||||
'Mario\'s Overalls',
|
|
||||||
'Betamax Player',
|
|
||||||
'Stray Lego',
|
|
||||||
'Chocolate Chip Pancakes',
|
|
||||||
'Two Blueberry Muffins',
|
|
||||||
'Nintendo 64 Controller with a Perfect Thumbstick',
|
|
||||||
'Cuckoo Crossing the Road',
|
|
||||||
'One Eyed, One Horned, Flying Purple People-Eater',
|
|
||||||
'Love Potion Number Nine',
|
|
||||||
'Wireless Headphones',
|
|
||||||
'Festive Keychain',
|
|
||||||
'Bundle of Twisted Cables',
|
|
||||||
'Plank of Wood',
|
|
||||||
'Broken Ant Farm',
|
|
||||||
'Thirty-six American Dollars',
|
|
||||||
'Can of Shaving Cream',
|
|
||||||
'Blue Hair Dye',
|
|
||||||
'Mug Engraved with the AP Logo',
|
|
||||||
'Tube of Toothpaste',
|
|
||||||
'Album of Elevator Music',
|
|
||||||
'Headlight Fluid',
|
|
||||||
'Tickets to the Renaissance Faire',
|
|
||||||
'Bag of Golf Balls',
|
|
||||||
'Box of Packing Peanuts',
|
|
||||||
'Bottle of Peanut Butter',
|
|
||||||
'Breath of the Wild Cookbook',
|
|
||||||
'Stardew Valley Cookbook',
|
|
||||||
'Thirteen Angry Chickens',
|
|
||||||
'Bowl of Cereal',
|
|
||||||
'Rubber Snake',
|
|
||||||
'Stale Sunflower Seeds',
|
|
||||||
'Alarm Clock Without a Snooze Button',
|
|
||||||
'Wet Pineapple',
|
|
||||||
'Set of Scented Candles',
|
|
||||||
'Adorable Stuffed Animal',
|
|
||||||
'The Broodwitch',
|
|
||||||
'Old Photo Album',
|
|
||||||
'Trade Quest Item',
|
|
||||||
'Pair of Fancy Boots',
|
|
||||||
'Shoddy Pickaxe',
|
|
||||||
'Adventurer\'s Sword',
|
|
||||||
'Cute Puppy',
|
|
||||||
'Box of Matches',
|
|
||||||
'Set of Allen Wrenches',
|
|
||||||
'Glass of Water',
|
|
||||||
'Magic Shaggy Carpet',
|
|
||||||
'Macaroni and Cheese',
|
|
||||||
'Chocolate Chip Cookie Dough Ice Cream',
|
|
||||||
'Fresh Strawberries',
|
|
||||||
'Delicious Tacos',
|
|
||||||
'The Krabby Patty Recipe',
|
|
||||||
'Map to Waldo\'s Location',
|
|
||||||
'Stray Cat',
|
|
||||||
'Ham and Cheese Sandwich',
|
|
||||||
'DVD Player',
|
|
||||||
'Motorcycle Helmet',
|
|
||||||
'Fake Flowers',
|
|
||||||
'6-Pack of Sponges',
|
|
||||||
'Heated Pants',
|
|
||||||
'Empty Glass Bottle',
|
|
||||||
'Brown Paper Bag',
|
|
||||||
'Model Train Set',
|
|
||||||
'TV Remote',
|
|
||||||
'RC Car',
|
|
||||||
'Super Soaker 9000',
|
|
||||||
'Giant Sunglasses',
|
|
||||||
'World\'s Smallest Violin',
|
|
||||||
'Pile of Fresh Warm Laundry',
|
|
||||||
'Half-Empty Ice Cube Tray',
|
|
||||||
'Bob Ross Afro Wig',
|
|
||||||
'Empty Cardboard Box',
|
|
||||||
'Packet of Soy Sauce',
|
|
||||||
'Solutions to a Math Test',
|
|
||||||
'Pencil Eraser',
|
|
||||||
'The Great Pumpkin',
|
|
||||||
'Very Expensive Toaster',
|
|
||||||
'Pack of Colored Sharpies',
|
|
||||||
'Bag of Chocolate Chips',
|
|
||||||
'Grandma\'s Homemade Cookies',
|
|
||||||
'Collection of Bottle Caps',
|
|
||||||
'Pack of Playing Cards',
|
|
||||||
'Boom Box',
|
|
||||||
'Toy Sail Boat',
|
|
||||||
'Smooth Nail File',
|
|
||||||
'Colored Chalk',
|
|
||||||
'Missing Button',
|
|
||||||
'Rubber Band Ball',
|
|
||||||
'Joystick',
|
|
||||||
'Galaga Arcade Cabinet',
|
|
||||||
'Anime Mouse Pad',
|
|
||||||
'Orange and Yellow Glow Sticks',
|
|
||||||
'Odd Bookmark',
|
|
||||||
'Stray Dice',
|
|
||||||
'Tooth Picks',
|
|
||||||
'Dirty Dishes',
|
|
||||||
'Poke\'mon Card Game Rule Book (Gen 1)',
|
|
||||||
'Salt Shaker',
|
|
||||||
'Digital Thermometer',
|
|
||||||
'Infinite Improbability Drive',
|
|
||||||
'Fire Extinguisher',
|
|
||||||
'Beeping Smoke Alarm',
|
|
||||||
'Greasy Spatula',
|
|
||||||
'Progressive Auto Insurance',
|
|
||||||
'Mace Windu\'s Purple Lightsaber',
|
|
||||||
'An Old Fixer-Upper',
|
|
||||||
'Gamer Chair',
|
|
||||||
'Comfortable Reclining Chair',
|
|
||||||
'Shirt Covered in Dog Hair',
|
|
||||||
'Angry Praying Mantis',
|
|
||||||
'Card Games on Motorcycles',
|
|
||||||
'Trucker Hat',
|
|
||||||
'The DK Rap',
|
|
||||||
'Three Great Balls',
|
|
||||||
'Some Very Sus Behavior',
|
|
||||||
'Glass of Orange Juice',
|
|
||||||
'Turkey Bacon',
|
|
||||||
'Bald Barbie Doll',
|
|
||||||
'Developer Commentary',
|
|
||||||
'Subscription to Nintendo Power Magazine',
|
|
||||||
'DeLorean Time Machine',
|
|
||||||
'Unkillable Cockroach',
|
|
||||||
'Dungeons & Dragons Rulebook',
|
|
||||||
'Boxed Copy of Quest 64',
|
|
||||||
'James Bond\'s Gadget Wristwatch',
|
|
||||||
'Tube of Go-Gurt',
|
|
||||||
'Digital Watch',
|
|
||||||
'Laser Pointer',
|
|
||||||
'The Secret Cow Level',
|
|
||||||
'AOL Free Trial CD-ROM',
|
|
||||||
'E.T. for Atari 2600',
|
|
||||||
'Season 2 of Knight Rider',
|
|
||||||
'Spam E-Mails',
|
|
||||||
'Half-Life 3 Release Date',
|
|
||||||
'Source Code of Jurassic Park',
|
|
||||||
'Moldy Cheese',
|
|
||||||
'Comic Book Collection',
|
|
||||||
'Hardcover Copy of Scott Pilgrim VS the World',
|
|
||||||
'Old Gym Shorts',
|
|
||||||
'Very Cool Sunglasses',
|
|
||||||
'Your High School Yearbook Picture',
|
|
||||||
'Written Invitation to Prom',
|
|
||||||
'The Star Wars Holiday Special',
|
|
||||||
'Oil Change Coupon',
|
|
||||||
'Finger Guns',
|
|
||||||
'Box of Tabletop Games',
|
|
||||||
'Sock Puppets',
|
|
||||||
'The Dog of Wisdom',
|
|
||||||
'Surprised Chipmunk',
|
|
||||||
'Stonks',
|
|
||||||
'A Shrubbery',
|
|
||||||
'Roomba with a Knife',
|
|
||||||
'Wet Cat',
|
|
||||||
'The missing moderator, Frostwares',
|
|
||||||
'1,793 Crossbows',
|
|
||||||
'Holographic First Edition Charizard (Gen 1)',
|
|
||||||
'VR Headset',
|
|
||||||
'Archipelago 1.0 Release Date',
|
|
||||||
'Strand of Galadriel\'s Hair',
|
|
||||||
'Can of Meow-Mix',
|
|
||||||
'Shake-Weight',
|
|
||||||
'DVD Collection of Billy Mays Infomercials',
|
|
||||||
'Old CD Key',
|
|
||||||
)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from BaseClasses import MultiWorld
|
|
||||||
from worlds.AutoWorld import LogicMixin
|
|
||||||
|
|
||||||
|
|
||||||
class ArchipIDLELogic(LogicMixin):
|
|
||||||
def _archipidle_location_is_accessible(self, player_id, items_required):
|
|
||||||
return sum(self.prog_items[player_id].values()) >= items_required
|
|
||||||
|
|
||||||
|
|
||||||
def set_rules(world: MultiWorld, player: int):
|
|
||||||
for i in range(16, 31):
|
|
||||||
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
|
||||||
state: state._archipidle_location_is_accessible(player, 4)
|
|
||||||
|
|
||||||
for i in range(31, 51):
|
|
||||||
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
|
||||||
state: state._archipidle_location_is_accessible(player, 10)
|
|
||||||
|
|
||||||
for i in range(51, 101):
|
|
||||||
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
|
||||||
state: state._archipidle_location_is_accessible(player, 20)
|
|
||||||
|
|
||||||
for i in range(101, 201):
|
|
||||||
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
|
||||||
state: state._archipidle_location_is_accessible(player, 40)
|
|
||||||
|
|
||||||
world.completion_condition[player] =\
|
|
||||||
lambda state: state.can_reach(world.get_location("IDLE item number 200", player), "Location", player)
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification
|
|
||||||
from worlds.AutoWorld import World, WebWorld
|
|
||||||
from datetime import datetime
|
|
||||||
from .Items import item_table
|
|
||||||
from .Rules import set_rules
|
|
||||||
|
|
||||||
|
|
||||||
class ArchipIDLEWebWorld(WebWorld):
|
|
||||||
theme = 'partyTime'
|
|
||||||
tutorials = [
|
|
||||||
Tutorial(
|
|
||||||
tutorial_name='Setup Guide',
|
|
||||||
description='A guide to playing Archipidle',
|
|
||||||
language='English',
|
|
||||||
file_name='guide_en.md',
|
|
||||||
link='guide/en',
|
|
||||||
authors=['Farrak Kilhn']
|
|
||||||
),
|
|
||||||
Tutorial(
|
|
||||||
tutorial_name='Guide d installation',
|
|
||||||
description='Un guide pour jouer à Archipidle',
|
|
||||||
language='Français',
|
|
||||||
file_name='guide_fr.md',
|
|
||||||
link='guide/fr',
|
|
||||||
authors=['TheLynk']
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ArchipIDLEWorld(World):
|
|
||||||
"""
|
|
||||||
An idle game which sends a check every thirty to sixty seconds, up to two hundred checks.
|
|
||||||
"""
|
|
||||||
game = "ArchipIDLE"
|
|
||||||
topology_present = False
|
|
||||||
hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April
|
|
||||||
web = ArchipIDLEWebWorld()
|
|
||||||
|
|
||||||
item_name_to_id = {}
|
|
||||||
start_id = 9000
|
|
||||||
for item in item_table:
|
|
||||||
item_name_to_id[item] = start_id
|
|
||||||
start_id += 1
|
|
||||||
|
|
||||||
location_name_to_id = {}
|
|
||||||
start_id = 9000
|
|
||||||
for i in range(1, 201):
|
|
||||||
location_name_to_id[f"IDLE item number {i}"] = start_id
|
|
||||||
start_id += 1
|
|
||||||
|
|
||||||
def set_rules(self):
|
|
||||||
set_rules(self.multiworld, self.player)
|
|
||||||
|
|
||||||
def create_item(self, name: str) -> Item:
|
|
||||||
return Item(name, ItemClassification.progression, self.item_name_to_id[name], self.player)
|
|
||||||
|
|
||||||
def create_items(self):
|
|
||||||
item_pool = [
|
|
||||||
ArchipIDLEItem(
|
|
||||||
item_table[0],
|
|
||||||
ItemClassification.progression,
|
|
||||||
self.item_name_to_id[item_table[0]],
|
|
||||||
self.player
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
for i in range(40):
|
|
||||||
item_pool.append(ArchipIDLEItem(
|
|
||||||
item_table[1],
|
|
||||||
ItemClassification.progression,
|
|
||||||
self.item_name_to_id[item_table[1]],
|
|
||||||
self.player
|
|
||||||
))
|
|
||||||
|
|
||||||
for i in range(40):
|
|
||||||
item_pool.append(ArchipIDLEItem(
|
|
||||||
item_table[2],
|
|
||||||
ItemClassification.filler,
|
|
||||||
self.item_name_to_id[item_table[2]],
|
|
||||||
self.player
|
|
||||||
))
|
|
||||||
|
|
||||||
item_table_copy = list(item_table[3:])
|
|
||||||
self.random.shuffle(item_table_copy)
|
|
||||||
for i in range(119):
|
|
||||||
item_pool.append(ArchipIDLEItem(
|
|
||||||
item_table_copy[i],
|
|
||||||
ItemClassification.progression if i < 9 else ItemClassification.filler,
|
|
||||||
self.item_name_to_id[item_table_copy[i]],
|
|
||||||
self.player
|
|
||||||
))
|
|
||||||
|
|
||||||
self.multiworld.itempool += item_pool
|
|
||||||
|
|
||||||
def create_regions(self):
|
|
||||||
self.multiworld.regions += [
|
|
||||||
create_region(self.multiworld, self.player, 'Menu', None, ['Entrance to IDLE Zone']),
|
|
||||||
create_region(self.multiworld, self.player, 'IDLE Zone', self.location_name_to_id)
|
|
||||||
]
|
|
||||||
|
|
||||||
# link up our region with the entrance we just made
|
|
||||||
self.multiworld.get_entrance('Entrance to IDLE Zone', self.player)\
|
|
||||||
.connect(self.multiworld.get_region('IDLE Zone', self.player))
|
|
||||||
|
|
||||||
def get_filler_item_name(self) -> str:
|
|
||||||
return self.multiworld.random.choice(item_table)
|
|
||||||
|
|
||||||
|
|
||||||
def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None):
|
|
||||||
region = Region(name, player, world)
|
|
||||||
if locations:
|
|
||||||
for location_name in locations.keys():
|
|
||||||
location = ArchipIDLELocation(player, location_name, locations[location_name], region)
|
|
||||||
region.locations.append(location)
|
|
||||||
|
|
||||||
if exits:
|
|
||||||
for _exit in exits:
|
|
||||||
region.exits.append(Entrance(player, _exit, region))
|
|
||||||
|
|
||||||
return region
|
|
||||||
|
|
||||||
|
|
||||||
class ArchipIDLEItem(Item):
|
|
||||||
game = "ArchipIDLE"
|
|
||||||
|
|
||||||
|
|
||||||
class ArchipIDLELocation(Location):
|
|
||||||
game: str = "ArchipIDLE"
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user