mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
267 Commits
0.3.3
...
player-tra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f5fceba2d | ||
|
|
813ee5ee3b | ||
|
|
be1158ad78 | ||
|
|
6d5ddf3cad | ||
|
|
809bda02d1 | ||
|
|
2d5ec6ce22 | ||
|
|
a95d0ce9ef | ||
|
|
267d9234e5 | ||
|
|
4686881566 | ||
|
|
101dab0ea4 | ||
|
|
c2d69cb05e | ||
|
|
58f66e0f42 | ||
|
|
0215e1fa28 | ||
|
|
1c0a93acad | ||
|
|
4fcde135e5 | ||
|
|
332dde154f | ||
|
|
8d51205e8f | ||
|
|
ff05e9d7d5 | ||
|
|
516a52c041 | ||
|
|
9daa64741b | ||
|
|
af11fa5150 | ||
|
|
156e9e0e43 | ||
|
|
ef46979bd8 | ||
|
|
b2aa251c47 | ||
|
|
e204a0fce6 | ||
|
|
bb386d3bd7 | ||
|
|
88a225764a | ||
|
|
99d2caa57d | ||
|
|
ade82e3d60 | ||
|
|
7c04e7e06f | ||
|
|
baf51e5959 | ||
|
|
8aad75ed23 | ||
|
|
1792b66b3a | ||
|
|
5e8ac74b2a | ||
|
|
2acc129381 | ||
|
|
0cbb3c2839 | ||
|
|
539d2e80f1 | ||
|
|
f9e28004a0 | ||
|
|
b7cfcc9272 | ||
|
|
4b6d46fd74 | ||
|
|
b45d8bf221 | ||
|
|
f7d107fc0c | ||
|
|
b14d694e1e | ||
|
|
8d2333006a | ||
|
|
e413619c26 | ||
|
|
03f66a922d | ||
|
|
b115bdafe7 | ||
|
|
0444fdc379 | ||
|
|
c617bba959 | ||
|
|
8da1cfeeb7 | ||
|
|
fcfc2c2e10 | ||
|
|
a753905ee4 | ||
|
|
2a7babce68 | ||
|
|
60d1a27079 | ||
|
|
4a2a184db1 | ||
|
|
45fb735320 | ||
|
|
3eb9e7050f | ||
|
|
26aed9351e | ||
|
|
b1ffbc49c9 | ||
|
|
6d6111de2a | ||
|
|
cc8ce32c61 | ||
|
|
4c94bb0ad5 | ||
|
|
af19180ff0 | ||
|
|
a175aa93e7 | ||
|
|
a78863fde1 | ||
|
|
0d6cbd9093 | ||
|
|
1aaf89ff2c | ||
|
|
295ea97544 | ||
|
|
33103b209d | ||
|
|
fab12dca0b | ||
|
|
c390801c4c | ||
|
|
e548abd332 | ||
|
|
0a5b24be2b | ||
|
|
7f41cafffc | ||
|
|
d66f981be6 | ||
|
|
b66a265726 | ||
|
|
c695f91198 | ||
|
|
11cbc0b40b | ||
|
|
87d91aeef3 | ||
|
|
6a6dfcbaff | ||
|
|
9553627136 | ||
|
|
a4a8894d22 | ||
|
|
bf217dcf85 | ||
|
|
484ee9f065 | ||
|
|
bba82ccd6c | ||
|
|
fb122df5f5 | ||
|
|
be8c3131d8 | ||
|
|
9341332379 | ||
|
|
83bcb441bf | ||
|
|
a074d16297 | ||
|
|
89ab4aff9c | ||
|
|
0ac67bfe76 | ||
|
|
e9e5511583 | ||
|
|
0d61192c67 | ||
|
|
a1aa9c17ff | ||
|
|
d0faa36eef | ||
|
|
22c8153ba8 | ||
|
|
6602c580f4 | ||
|
|
431a9b7023 | ||
|
|
d426226bce | ||
|
|
09afdc2553 | ||
|
|
c546dcd5ff | ||
|
|
ca83905d9f | ||
|
|
086295adbb | ||
|
|
81cf1508e0 | ||
|
|
8484193151 | ||
|
|
d10fbf8263 | ||
|
|
f73b3d71bf | ||
|
|
d48d775a59 | ||
|
|
f716bfc58f | ||
|
|
97b388747a | ||
|
|
898fa203ad | ||
|
|
c02c6ee58c | ||
|
|
23b04b5069 | ||
|
|
0ed0d17f38 | ||
|
|
645ede869f | ||
|
|
f5e48c850d | ||
|
|
9bd035a19d | ||
|
|
2e428f906c | ||
|
|
b702ae482b | ||
|
|
b8ca41b45f | ||
|
|
adc16fdd3d | ||
|
|
b32d0efe6d | ||
|
|
c96acbfa23 | ||
|
|
ffe528467e | ||
|
|
b989698740 | ||
|
|
29e0975832 | ||
|
|
e1e2526322 | ||
|
|
f2e83c37e9 | ||
|
|
debda5d111 | ||
|
|
2c4e819010 | ||
|
|
b3700dabf2 | ||
|
|
fb2979d9ef | ||
|
|
a378d62dfd | ||
|
|
eb5ba72cfc | ||
|
|
c1e9d0ab4f | ||
|
|
181cc47079 | ||
|
|
04eef669f9 | ||
|
|
9167e5363d | ||
|
|
f1c5c9a148 | ||
|
|
69e5627cd7 | ||
|
|
ae3e6c29e3 | ||
|
|
f6da81ac70 | ||
|
|
dd6e212519 | ||
|
|
95bba50223 | ||
|
|
21f7c6c0ad | ||
|
|
d15c30f63b | ||
|
|
db5b7e5db9 | ||
|
|
7c808bb03b | ||
|
|
530b6cc360 | ||
|
|
95012c004f | ||
|
|
053fb14495 | ||
|
|
ed77d14618 | ||
|
|
3fb287e82b | ||
|
|
32431cfe04 | ||
|
|
59918b9dbc | ||
|
|
b47cca4515 | ||
|
|
5f27019855 | ||
|
|
0b228834c2 | ||
|
|
57979b9287 | ||
|
|
4b85000960 | ||
|
|
ca8f4c38ec | ||
|
|
d1f34d088b | ||
|
|
eb52454ccc | ||
|
|
3bc9392e5b | ||
|
|
75165803a0 | ||
|
|
afc9c772be | ||
|
|
07450bb83d | ||
|
|
2ff7e83ad9 | ||
|
|
d817fdcfdb | ||
|
|
f3d966897f | ||
|
|
9acaf1c279 | ||
|
|
fd6a0b547f | ||
|
|
c02f355479 | ||
|
|
7d9203ef84 | ||
|
|
e849e4792d | ||
|
|
4565b3af8d | ||
|
|
e5b868e0e9 | ||
|
|
489450d3fa | ||
|
|
73afab67c8 | ||
|
|
14e5f54f59 | ||
|
|
c61f77029b | ||
|
|
79702aba65 | ||
|
|
1e366ff66f | ||
|
|
a0482cf27e | ||
|
|
288a623ab6 | ||
|
|
3b2037a2d4 | ||
|
|
ce536fa3ac | ||
|
|
41883e44e7 | ||
|
|
c3ff201b90 | ||
|
|
e6635cdd77 | ||
|
|
cfc9d79c79 | ||
|
|
fe2c355739 | ||
|
|
04c3429839 | ||
|
|
cabbe0aaf6 | ||
|
|
a7787d87f9 | ||
|
|
79b851189f | ||
|
|
9e972eafb2 | ||
|
|
53a995372f | ||
|
|
17351021b3 | ||
|
|
8ff2c1b6f3 | ||
|
|
45aea2c8ff | ||
|
|
2052cc55af | ||
|
|
9f5e40283a | ||
|
|
025309ec64 | ||
|
|
bd4850b2b5 | ||
|
|
472e114fb9 | ||
|
|
828bcb1266 | ||
|
|
9897f4eb4b | ||
|
|
e1ef820184 | ||
|
|
b3ad766680 | ||
|
|
74b19dc1f5 | ||
|
|
449bc93307 | ||
|
|
622af17705 | ||
|
|
a42f7f99fe | ||
|
|
3c6bd555b4 | ||
|
|
a4211d5f11 | ||
|
|
090c5bcf00 | ||
|
|
82850d7f66 | ||
|
|
86112351a6 | ||
|
|
ce789d1e3e | ||
|
|
73fb1b8074 | ||
|
|
8e15fe51b6 | ||
|
|
aa954b776d | ||
|
|
76f6eb1434 | ||
|
|
e38308bac3 | ||
|
|
e804f592de | ||
|
|
6e0a0c5c4a | ||
|
|
122590fc68 | ||
|
|
c806366469 | ||
|
|
63a8436240 | ||
|
|
0d3bd6e2e8 | ||
|
|
beac0b1acd | ||
|
|
1cc9c7a469 | ||
|
|
17db0805a7 | ||
|
|
2f53972c85 | ||
|
|
9ac780102e | ||
|
|
60b80083e0 | ||
|
|
8597b04c41 | ||
|
|
6a60c46a99 | ||
|
|
5c2163a1a7 | ||
|
|
a49bcd618d | ||
|
|
d76b41afe7 | ||
|
|
ab2b635a77 | ||
|
|
7072c7bd45 | ||
|
|
530c5500c3 | ||
|
|
8870b577d0 | ||
|
|
7d85ab471a | ||
|
|
3205cbf932 | ||
|
|
b9fb4de878 | ||
|
|
bcd7096e1d | ||
|
|
b206f2846a | ||
|
|
8a8bc6aa34 | ||
|
|
bce7c258c3 | ||
|
|
cea7278faf | ||
|
|
d7a9b98ce8 | ||
|
|
7dcde12e2e | ||
|
|
ba2a5c4744 | ||
|
|
39ac3c38bf | ||
|
|
61f751a1db | ||
|
|
5f2193f2e4 | ||
|
|
98b714f84a | ||
|
|
2a0198b618 | ||
|
|
e60719a20a | ||
|
|
cd9f8f3119 | ||
|
|
37b569eca6 | ||
|
|
8742aadc72 |
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: File a bug report.
|
||||||
|
title: "Bug: "
|
||||||
|
labels:
|
||||||
|
- bug / fix
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
|
||||||
|
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
|
||||||
|
and upload it with this report, as well as all yaml files used.
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-results
|
||||||
|
attributes:
|
||||||
|
label: What were the expected results?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Software
|
||||||
|
description: Where did this bug occur?
|
||||||
|
options:
|
||||||
|
- Website
|
||||||
|
- Local generation
|
||||||
|
- While playing
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Request a feature!
|
||||||
|
title: "Category: "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
|
||||||
|
website, documentation, or a game.
|
||||||
|
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
|
||||||
|
ask is about it is in the [discord](https://archipelago.gg/discord).
|
||||||
|
- type: textarea
|
||||||
|
id: feature
|
||||||
|
attributes:
|
||||||
|
label: What feature would you like to see?
|
||||||
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: Task
|
||||||
|
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
|
||||||
|
title: "Core: "
|
||||||
|
labels:
|
||||||
|
- core
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: What task needs to be completed?
|
||||||
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Please format your title with what portion of the project this pull request is
|
||||||
|
targeting and what it's changing.
|
||||||
|
|
||||||
|
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
|
||||||
|
|
||||||
|
## What is this fixing or adding?
|
||||||
|
|
||||||
|
|
||||||
|
## How was this tested?
|
||||||
|
|
||||||
|
|
||||||
|
## If this makes graphical changes, please attach screenshots.
|
||||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -4,6 +4,11 @@ name: Build
|
|||||||
|
|
||||||
on: workflow_dispatch
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
env:
|
||||||
|
SNI_VERSION: v0.0.84
|
||||||
|
ENEMIZER_VERSION: 7.1
|
||||||
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
@@ -17,9 +22,9 @@ jobs:
|
|||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-windows-amd64.zip -OutFile sni.zip
|
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
|
||||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
@@ -43,6 +48,7 @@ jobs:
|
|||||||
build-ubuntu1804:
|
build-ubuntu1804:
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-18.04
|
||||||
steps:
|
steps:
|
||||||
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install base dependencies
|
- name: Install base dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -56,18 +62,18 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
|
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||||
tar xf sni-*.tar.xz
|
tar xf sni-*.tar.xz
|
||||||
rm sni-*.tar.xz
|
rm sni-*.tar.xz
|
||||||
mv sni-* SNI
|
mv sni-* SNI
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0/ubuntu.16.04-x64.7z
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
@@ -84,6 +90,7 @@ jobs:
|
|||||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -18,8 +18,8 @@ jobs:
|
|||||||
python-version: 3.9
|
python-version: 3.9
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip wheel
|
||||||
pip install flake8 pytest
|
pip install flake8 pytest pytest-subtests
|
||||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
- name: Lint with flake8
|
- name: Lint with flake8
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -7,6 +7,11 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
SNI_VERSION: v0.0.84
|
||||||
|
ENEMIZER_VERSION: 7.1
|
||||||
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -44,22 +49,23 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
|
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||||
tar xf sni-*.tar.xz
|
tar xf sni-*.tar.xz
|
||||||
rm sni-*.tar.xz
|
rm sni-*.tar.xz
|
||||||
mv sni-* SNI
|
mv sni-* SNI
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0/ubuntu.16.04-x64.7z
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
# pygobject is an optional dependency for kivy that's not in requirements
|
||||||
|
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|||||||
4
.github/workflows/unittests.yml
vendored
4
.github/workflows/unittests.yml
vendored
@@ -32,8 +32,8 @@ jobs:
|
|||||||
python-version: ${{ matrix.python.version }}
|
python-version: ${{ matrix.python.version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip wheel
|
||||||
pip install flake8 pytest
|
pip install flake8 pytest pytest-subtests
|
||||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||||
- name: Unittests
|
- name: Unittests
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -28,6 +28,7 @@ README.html
|
|||||||
.vs/
|
.vs/
|
||||||
EnemizerCLI/
|
EnemizerCLI/
|
||||||
/Players/
|
/Players/
|
||||||
|
/SNI/
|
||||||
/options.yaml
|
/options.yaml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
/logs/
|
/logs/
|
||||||
@@ -116,6 +117,9 @@ target/
|
|||||||
profile_default/
|
profile_default/
|
||||||
ipython_config.py
|
ipython_config.py
|
||||||
|
|
||||||
|
# vim editor
|
||||||
|
*.swp
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
@@ -152,10 +156,17 @@ dmypy.json
|
|||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
#minecraft server stuff
|
# minecraft server stuff
|
||||||
jdk*/
|
jdk*/
|
||||||
minecraft*/
|
minecraft*/
|
||||||
minecraft_versions.json
|
minecraft_versions.json
|
||||||
|
|
||||||
#pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
# OS General Files
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Thumbs.db
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|||||||
104
BaseClasses.py
104
BaseClasses.py
@@ -126,7 +126,6 @@ class MultiWorld():
|
|||||||
set_player_attr('beemizer_total_chance', 0)
|
set_player_attr('beemizer_total_chance', 0)
|
||||||
set_player_attr('beemizer_trap_chance', 0)
|
set_player_attr('beemizer_trap_chance', 0)
|
||||||
set_player_attr('escape_assist', [])
|
set_player_attr('escape_assist', [])
|
||||||
set_player_attr('open_pyramid', False)
|
|
||||||
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
|
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
|
||||||
set_player_attr('treasure_hunt_count', 0)
|
set_player_attr('treasure_hunt_count', 0)
|
||||||
set_player_attr('clock_mode', False)
|
set_player_attr('clock_mode', False)
|
||||||
@@ -167,7 +166,7 @@ class MultiWorld():
|
|||||||
self.player_types[new_id] = NetUtils.SlotType.group
|
self.player_types[new_id] = NetUtils.SlotType.group
|
||||||
self._region_cache[new_id] = {}
|
self._region_cache[new_id] = {}
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
world_type = AutoWorld.AutoWorldRegister.world_types[game]
|
||||||
for option_key, option in world_type.options.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
getattr(self, option_key)[new_id] = option(option.default)
|
getattr(self, option_key)[new_id] = option(option.default)
|
||||||
for option_key, option in Options.common_options.items():
|
for option_key, option in Options.common_options.items():
|
||||||
getattr(self, option_key)[new_id] = option(option.default)
|
getattr(self, option_key)[new_id] = option(option.default)
|
||||||
@@ -205,7 +204,7 @@ class MultiWorld():
|
|||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
self.custom_data[player] = {}
|
self.custom_data[player] = {}
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
for option_key in world_type.options:
|
for option_key in world_type.option_definitions:
|
||||||
setattr(self, option_key, getattr(args, option_key, {}))
|
setattr(self, option_key, getattr(args, option_key, {}))
|
||||||
|
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
@@ -385,25 +384,17 @@ class MultiWorld():
|
|||||||
return self.worlds[player].create_item(item_name)
|
return self.worlds[player].create_item(item_name)
|
||||||
|
|
||||||
def push_precollected(self, item: Item):
|
def push_precollected(self, item: Item):
|
||||||
item.world = self
|
|
||||||
self.precollected_items[item.player].append(item)
|
self.precollected_items[item.player].append(item)
|
||||||
self.state.collect(item, True)
|
self.state.collect(item, True)
|
||||||
|
|
||||||
def push_item(self, location: Location, item: Item, collect: bool = True):
|
def push_item(self, location: Location, item: Item, collect: bool = True):
|
||||||
if not isinstance(location, Location):
|
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
|
||||||
raise RuntimeError(
|
location.item = item
|
||||||
'Cannot assign item %s to invalid location %s (player %d).' % (item, location, item.player))
|
item.location = location
|
||||||
|
if collect:
|
||||||
|
self.state.collect(item, location.event, location)
|
||||||
|
|
||||||
if location.can_fill(self.state, item, False):
|
logging.debug('Placed %s at %s', item, location)
|
||||||
location.item = item
|
|
||||||
item.location = location
|
|
||||||
item.world = self # try to not have this here anymore
|
|
||||||
if collect:
|
|
||||||
self.state.collect(item, location.event, location)
|
|
||||||
|
|
||||||
logging.debug('Placed %s at %s', item, location)
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Cannot assign item %s to location %s.' % (item, location))
|
|
||||||
|
|
||||||
def get_entrances(self) -> List[Entrance]:
|
def get_entrances(self) -> List[Entrance]:
|
||||||
if self._cached_entrances is None:
|
if self._cached_entrances is None:
|
||||||
@@ -964,6 +955,13 @@ class Region:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
|
||||||
|
for entrance in self.entrances:
|
||||||
|
if is_main_entrance(entrance):
|
||||||
|
return entrance
|
||||||
|
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||||
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
@@ -1073,26 +1071,25 @@ class LocationProgressType(IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
class Location:
|
class Location:
|
||||||
# If given as integer, then this is the shop's inventory index
|
game: str = "Generic"
|
||||||
shop_slot: Optional[int] = None
|
player: int
|
||||||
shop_slot_disabled: bool = False
|
name: str
|
||||||
|
address: Optional[int]
|
||||||
|
parent_region: Optional[Region]
|
||||||
event: bool = False
|
event: bool = False
|
||||||
locked: bool = False
|
locked: bool = False
|
||||||
game: str = "Generic"
|
|
||||||
show_in_spoiler: bool = True
|
show_in_spoiler: bool = True
|
||||||
crystal: bool = False
|
|
||||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||||
always_allow = staticmethod(lambda item, state: False)
|
always_allow = staticmethod(lambda item, state: False)
|
||||||
access_rule = staticmethod(lambda state: True)
|
access_rule = staticmethod(lambda state: True)
|
||||||
item_rule = staticmethod(lambda item: True)
|
item_rule = staticmethod(lambda item: True)
|
||||||
item: Optional[Item] = None
|
item: Optional[Item] = None
|
||||||
parent_region: Optional[Region]
|
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
|
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
|
||||||
self.name: str = name
|
self.player = player
|
||||||
self.address: Optional[int] = address
|
self.name = name
|
||||||
|
self.address = address
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
self.player: int = player
|
|
||||||
|
|
||||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||||
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
|
||||||
@@ -1109,7 +1106,6 @@ class Location:
|
|||||||
self.item = item
|
self.item = item
|
||||||
item.location = self
|
item.location = self
|
||||||
self.event = item.advancement
|
self.event = item.advancement
|
||||||
self.item.world = self.parent_region.world
|
|
||||||
self.locked = True
|
self.locked = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -1154,56 +1150,45 @@ class ItemClassification(IntFlag):
|
|||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
location: Optional[Location] = None
|
|
||||||
world: Optional[MultiWorld] = None
|
|
||||||
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
|
|
||||||
name: str
|
|
||||||
game: str = "Generic"
|
game: str = "Generic"
|
||||||
type: str = None
|
__slots__ = ("name", "classification", "code", "player", "location")
|
||||||
|
name: str
|
||||||
classification: ItemClassification
|
classification: ItemClassification
|
||||||
|
code: Optional[int]
|
||||||
# need to find a decent place for these to live and to allow other games to register texts if they want.
|
"""an item with code None is called an Event, and does not get written to multidata"""
|
||||||
pedestal_credit_text: str = "and the Unknown Item"
|
player: int
|
||||||
sickkid_credit_text: Optional[str] = None
|
location: Optional[Location]
|
||||||
magicshop_credit_text: Optional[str] = None
|
|
||||||
zora_credit_text: Optional[str] = None
|
|
||||||
fluteboy_credit_text: Optional[str] = None
|
|
||||||
|
|
||||||
# hopefully temporary attributes to satisfy legacy LttP code, proper implementation in subclass ALttPItem
|
|
||||||
smallkey: bool = False
|
|
||||||
bigkey: bool = False
|
|
||||||
map: bool = False
|
|
||||||
compass: bool = False
|
|
||||||
|
|
||||||
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
|
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.classification = classification
|
self.classification = classification
|
||||||
self.player = player
|
self.player = player
|
||||||
self.code = code
|
self.code = code
|
||||||
|
self.location = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hint_text(self):
|
def hint_text(self) -> str:
|
||||||
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
|
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pedestal_hint_text(self):
|
def pedestal_hint_text(self) -> str:
|
||||||
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
|
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def advancement(self) -> bool:
|
def advancement(self) -> bool:
|
||||||
return bool(self.classification & ItemClassification.progression)
|
return ItemClassification.progression in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def skip_in_prog_balancing(self) -> bool:
|
def skip_in_prog_balancing(self) -> bool:
|
||||||
return self.classification == ItemClassification.progression_skip_balancing
|
return ItemClassification.progression_skip_balancing in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def useful(self) -> bool:
|
def useful(self) -> bool:
|
||||||
return bool(self.classification & ItemClassification.useful)
|
return ItemClassification.useful in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return bool(self.classification & ItemClassification.trap)
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
@@ -1212,7 +1197,7 @@ class Item:
|
|||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
return self.name == other.name and self.player == other.player
|
return self.name == other.name and self.player == other.player
|
||||||
|
|
||||||
def __lt__(self, other: Item):
|
def __lt__(self, other: Item) -> bool:
|
||||||
if other.player != self.player:
|
if other.player != self.player:
|
||||||
return other.player < self.player
|
return other.player < self.player
|
||||||
return self.name < other.name
|
return self.name < other.name
|
||||||
@@ -1220,11 +1205,13 @@ class Item:
|
|||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash((self.name, self.player))
|
return hash((self.name, self.player))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self) -> str:
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
|
if self.location and self.location.parent_region and self.location.parent_region.world:
|
||||||
|
return self.location.parent_region.world.get_name_string_for_object(self)
|
||||||
|
return f"{self.name} (Player {self.player})"
|
||||||
|
|
||||||
|
|
||||||
class Spoiler():
|
class Spoiler():
|
||||||
@@ -1408,7 +1395,7 @@ class Spoiler():
|
|||||||
outfile.write('Game: %s\n' % self.world.game[player])
|
outfile.write('Game: %s\n' % self.world.game[player])
|
||||||
for f_option, option in Options.per_game_common_options.items():
|
for f_option, option in Options.per_game_common_options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
options = self.world.worlds[player].options
|
options = self.world.worlds[player].option_definitions
|
||||||
if options:
|
if options:
|
||||||
for f_option, option in options.items():
|
for f_option, option in options.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
@@ -1431,8 +1418,6 @@ class Spoiler():
|
|||||||
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player])
|
||||||
if self.world.shuffle[player] != "vanilla":
|
if self.world.shuffle[player] != "vanilla":
|
||||||
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed)
|
||||||
outfile.write('Pyramid hole pre-opened: %s\n' % (
|
|
||||||
'Yes' if self.world.open_pyramid[player] else 'No'))
|
|
||||||
outfile.write('Shop inventory shuffle: %s\n' %
|
outfile.write('Shop inventory shuffle: %s\n' %
|
||||||
bool_to_text("i" in self.world.shop_shuffle[player]))
|
bool_to_text("i" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Shop price shuffle: %s\n' %
|
outfile.write('Shop price shuffle: %s\n' %
|
||||||
@@ -1444,7 +1429,6 @@ class Spoiler():
|
|||||||
"f" in self.world.shop_shuffle[player]))
|
"f" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Custom Potion Shop: %s\n' %
|
outfile.write('Custom Potion Shop: %s\n' %
|
||||||
bool_to_text("w" in self.world.shop_shuffle[player]))
|
bool_to_text("w" in self.world.shop_shuffle[player]))
|
||||||
outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
|
|
||||||
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
|
||||||
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
|
||||||
outfile.write('Prize shuffle %s\n' %
|
outfile.write('Prize shuffle %s\n' %
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -32,20 +34,34 @@ class ChecksFinderContext(CommonContext):
|
|||||||
self.send_index: int = 0
|
self.send_index: int = 0
|
||||||
self.syncing = False
|
self.syncing = False
|
||||||
self.awaiting_bridge = False
|
self.awaiting_bridge = False
|
||||||
|
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||||
|
if "localappdata" in os.environ:
|
||||||
|
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||||
|
else:
|
||||||
|
# not windows. game is an exe so let's see if wine might be around to run it
|
||||||
|
if "WINEPREFIX" in os.environ:
|
||||||
|
wineprefix = os.environ["WINEPREFIX"]
|
||||||
|
elif shutil.which("wine") or shutil.which("wine-stable"):
|
||||||
|
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
|
||||||
|
else:
|
||||||
|
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
|
||||||
|
logger.error("Error: " + msg)
|
||||||
|
Utils.messagebox("Error", msg, error=True)
|
||||||
|
sys.exit(1)
|
||||||
|
self.game_communication_path = os.path.join(
|
||||||
|
wineprefix,
|
||||||
|
"drive_c",
|
||||||
|
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
await super(ChecksFinderContext, self).server_auth(password_requested)
|
await super(ChecksFinderContext, self).server_auth(password_requested)
|
||||||
if not self.auth: # TODO: Replace this if block with await self.getusername() once that PR is merged in.
|
await self.get_username()
|
||||||
logger.info('Enter slot name:')
|
|
||||||
self.auth = await self.console_input()
|
|
||||||
|
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
|
|
||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
await super(ChecksFinderContext, self).connection_closed()
|
await super(ChecksFinderContext, self).connection_closed()
|
||||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
for root, dirs, files in os.walk(self.game_communication_path):
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.find("obtain") <= -1:
|
if file.find("obtain") <= -1:
|
||||||
os.remove(root + "/" + file)
|
os.remove(root + "/" + file)
|
||||||
@@ -59,26 +75,25 @@ class ChecksFinderContext(CommonContext):
|
|||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await super(ChecksFinderContext, self).shutdown()
|
await super(ChecksFinderContext, self).shutdown()
|
||||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
for root, dirs, files in os.walk(self.game_communication_path):
|
||||||
for root, dirs, files in os.walk(path):
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.find("obtain") <= -1:
|
if file.find("obtain") <= -1:
|
||||||
os.remove(root+"/"+file)
|
os.remove(root+"/"+file)
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd in {"Connected"}:
|
if cmd in {"Connected"}:
|
||||||
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
|
if not os.path.exists(self.game_communication_path):
|
||||||
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
|
os.makedirs(self.game_communication_path)
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
f.close()
|
f.close()
|
||||||
if cmd in {"ReceivedItems"}:
|
if cmd in {"ReceivedItems"}:
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
if start_index != len(self.items_received):
|
if start_index != len(self.items_received):
|
||||||
for item in args['items']:
|
for item in args['items']:
|
||||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
f.write(str(NetworkItem(*item).item))
|
f.write(str(NetworkItem(*item).item))
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
@@ -86,7 +101,7 @@ class ChecksFinderContext(CommonContext):
|
|||||||
if "checked_locations" in args:
|
if "checked_locations" in args:
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
@@ -112,10 +127,9 @@ async def game_watcher(ctx: ChecksFinderContext):
|
|||||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||||
await ctx.send_msgs(sync_msg)
|
await ctx.send_msgs(sync_msg)
|
||||||
ctx.syncing = False
|
ctx.syncing = False
|
||||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
|
||||||
sending = []
|
sending = []
|
||||||
victory = False
|
victory = False
|
||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.find("send") > -1:
|
if file.find("send") > -1:
|
||||||
st = file.split("send", -1)[1]
|
st = file.split("send", -1)[1]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import urllib.parse
|
|||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
|
import functools
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -17,7 +18,8 @@ if __name__ == "__main__":
|
|||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||||
|
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||||
from Utils import Version, stream_input
|
from Utils import Version, stream_input
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -43,12 +45,14 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
def _cmd_connect(self, address: str = "") -> bool:
|
def _cmd_connect(self, address: str = "") -> bool:
|
||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
self.ctx.server_address = None
|
||||||
|
self.ctx.username = None
|
||||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_disconnect(self) -> bool:
|
def _cmd_disconnect(self) -> bool:
|
||||||
"""Disconnect from a MultiWorld Server"""
|
"""Disconnect from a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
self.ctx.server_address = None
|
||||||
|
self.ctx.username = None
|
||||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -150,8 +154,9 @@ class CommonContext:
|
|||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: typing.Set[int] # local state
|
||||||
locations_scouted: typing.Set[int]
|
locations_scouted: typing.Set[int]
|
||||||
missing_locations: typing.Set[int]
|
missing_locations: typing.Set[int] # server state
|
||||||
checked_locations: typing.Set[int] # server state
|
checked_locations: typing.Set[int] # server state
|
||||||
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
locations_info: typing.Dict[int, NetworkItem]
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
@@ -161,6 +166,7 @@ class CommonContext:
|
|||||||
def __init__(self, server_address, password):
|
def __init__(self, server_address, password):
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
|
self.username = None
|
||||||
self.password = password
|
self.password = password
|
||||||
self.hint_cost = None
|
self.hint_cost = None
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
@@ -181,8 +187,9 @@ class CommonContext:
|
|||||||
self.locations_checked = set() # local state
|
self.locations_checked = set() # local state
|
||||||
self.locations_scouted = set()
|
self.locations_scouted = set()
|
||||||
self.items_received = []
|
self.items_received = []
|
||||||
self.missing_locations = set()
|
self.missing_locations = set() # server state
|
||||||
self.checked_locations = set() # server state
|
self.checked_locations = set() # server state
|
||||||
|
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
|
||||||
self.locations_info = {}
|
self.locations_info = {}
|
||||||
|
|
||||||
self.input_queue = asyncio.Queue()
|
self.input_queue = asyncio.Queue()
|
||||||
@@ -199,6 +206,10 @@ class CommonContext:
|
|||||||
# execution
|
# execution
|
||||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def raw_text_parser(self) -> RawJSONtoTextParser:
|
||||||
|
return RawJSONtoTextParser(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_locations(self) -> typing.Optional[int]:
|
def total_locations(self) -> typing.Optional[int]:
|
||||||
"""Will return None until connected."""
|
"""Will return None until connected."""
|
||||||
@@ -253,6 +264,13 @@ class CommonContext:
|
|||||||
self.password = await self.console_input()
|
self.password = await self.console_input()
|
||||||
return self.password
|
return self.password
|
||||||
|
|
||||||
|
async def get_username(self):
|
||||||
|
if not self.auth:
|
||||||
|
self.auth = self.username
|
||||||
|
if not self.auth:
|
||||||
|
logger.info('Enter slot name:')
|
||||||
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs):
|
async def send_connect(self, **kwargs):
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
@@ -309,6 +327,7 @@ class CommonContext:
|
|||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
self.server_address = ""
|
self.server_address = ""
|
||||||
|
self.username = None
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task:
|
if self.server_task:
|
||||||
@@ -334,6 +353,8 @@ class CommonContext:
|
|||||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
|
if game not in remote_datepackage_versions:
|
||||||
|
continue
|
||||||
remote_version: int = remote_datepackage_versions[game]
|
remote_version: int = remote_datepackage_versions[game]
|
||||||
|
|
||||||
if remote_version == 0: # custom datapackage for this game
|
if remote_version == 0: # custom datapackage for this game
|
||||||
@@ -469,12 +490,21 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
logger.info('Please connect to an Archipelago server.')
|
logger.info('Please connect to an Archipelago server.')
|
||||||
return
|
return
|
||||||
|
|
||||||
address = f"ws://{address}" if "://" not in address else address
|
address = f"ws://{address}" if "://" not in address \
|
||||||
port = urllib.parse.urlparse(address).port or 38281
|
else address.replace("archipelago://", "ws://")
|
||||||
|
|
||||||
|
server_url = urllib.parse.urlparse(address)
|
||||||
|
if server_url.username:
|
||||||
|
ctx.username = server_url.username
|
||||||
|
if server_url.password:
|
||||||
|
ctx.password = server_url.password
|
||||||
|
port = server_url.port or 38281
|
||||||
|
|
||||||
logger.info(f'Connecting to Archipelago server at {address}')
|
logger.info(f'Connecting to Archipelago server at {address}')
|
||||||
try:
|
try:
|
||||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||||
|
if ctx.ui is not None:
|
||||||
|
ctx.ui.update_address_bar(server_url.netloc)
|
||||||
ctx.server = Endpoint(socket)
|
ctx.server = Endpoint(socket)
|
||||||
logger.info('Connected')
|
logger.info('Connected')
|
||||||
ctx.server_address = address
|
ctx.server_address = address
|
||||||
@@ -543,18 +573,21 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
f" for each location checked. Use !hint for more information.")
|
f" for each location checked. Use !hint for more information.")
|
||||||
ctx.hint_cost = int(args['hint_cost'])
|
ctx.hint_cost = int(args['hint_cost'])
|
||||||
ctx.check_points = int(args['location_check_points'])
|
ctx.check_points = int(args['location_check_points'])
|
||||||
players = args.get("players", [])
|
|
||||||
if len(players) < 1:
|
if "players" in args: # TODO remove when servers sending this are outdated
|
||||||
logger.info('No player connected')
|
players = args.get("players", [])
|
||||||
else:
|
if len(players) < 1:
|
||||||
players.sort()
|
logger.info('No player connected')
|
||||||
current_team = -1
|
else:
|
||||||
logger.info('Connected Players:')
|
players.sort()
|
||||||
for network_player in players:
|
current_team = -1
|
||||||
if network_player.team != current_team:
|
logger.info('Connected Players:')
|
||||||
logger.info(f' Team #{network_player.team + 1}')
|
for network_player in players:
|
||||||
current_team = network_player.team
|
if network_player.team != current_team:
|
||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(f' Team #{network_player.team + 1}')
|
||||||
|
current_team = network_player.team
|
||||||
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update datapackage
|
# update datapackage
|
||||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||||
|
|
||||||
@@ -585,6 +618,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||||
|
|
||||||
elif cmd == 'Connected':
|
elif cmd == 'Connected':
|
||||||
|
ctx.username = ctx.auth
|
||||||
ctx.team = args["team"]
|
ctx.team = args["team"]
|
||||||
ctx.slot = args["slot"]
|
ctx.slot = args["slot"]
|
||||||
# int keys get lost in JSON transfer
|
# int keys get lost in JSON transfer
|
||||||
@@ -608,6 +642,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
# when /missing is used for the client side view of what is missing.
|
# when /missing is used for the client side view of what is missing.
|
||||||
ctx.missing_locations = set(args["missing_locations"])
|
ctx.missing_locations = set(args["missing_locations"])
|
||||||
ctx.checked_locations = set(args["checked_locations"])
|
ctx.checked_locations = set(args["checked_locations"])
|
||||||
|
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||||
|
|
||||||
elif cmd == 'ReceivedItems':
|
elif cmd == 'ReceivedItems':
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
@@ -703,15 +738,12 @@ if __name__ == '__main__':
|
|||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||||
game = "" # empty matches any game since 0.3.2
|
game = "" # empty matches any game since 0.3.2
|
||||||
items_handling = 0 # don't receive any NetworkItems
|
items_handling = 0b111 # receive all items for /received
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
await super(TextContext, self).server_auth(password_requested)
|
await super(TextContext, self).server_auth(password_requested)
|
||||||
if not self.auth:
|
await self.get_username()
|
||||||
logger.info('Enter slot name:')
|
|
||||||
self.auth = await self.console_input()
|
|
||||||
|
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
@@ -722,6 +754,7 @@ if __name__ == '__main__':
|
|||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = TextContext(args.connect, args.password)
|
ctx = TextContext(args.connect, args.password)
|
||||||
ctx.auth = args.name
|
ctx.auth = args.name
|
||||||
|
ctx.server_address = args.connect
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
|
|||||||
53
FF1Client.py
53
FF1Client.py
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
@@ -6,7 +7,7 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
get_base_parser
|
get_base_parser
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
@@ -64,7 +65,7 @@ class FF1Context(CommonContext):
|
|||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
def _set_message(self, msg: str, msg_id: int):
|
||||||
if DISPLAY_MSGS:
|
if DISPLAY_MSGS:
|
||||||
self.messages[(time.time(), msg_id)] = msg
|
self.messages[time.time(), msg_id] = msg
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
@@ -73,32 +74,28 @@ class FF1Context(CommonContext):
|
|||||||
msg = args['text']
|
msg = args['text']
|
||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
elif cmd == "ReceivedItems":
|
|
||||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
def on_print_json(self, args: dict):
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
if self.ui:
|
||||||
elif cmd == 'PrintJSON':
|
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||||
print_type = args['type']
|
else:
|
||||||
item = args['item']
|
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||||
receiving_player_id = args['receiving']
|
logger.info(text)
|
||||||
receiving_player_name = self.player_names[receiving_player_id]
|
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||||
sending_player_id = item.player
|
if relevant:
|
||||||
sending_player_name = self.player_names[item.player]
|
item = args["item"]
|
||||||
if print_type == 'Hint':
|
# goes to this world
|
||||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
if self.slot_concerns_self(args["receiving"]):
|
||||||
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
relevant = True
|
||||||
self._set_message(msg, item.item)
|
# found in this world
|
||||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
elif self.slot_concerns_self(item.player):
|
||||||
if sending_player_id == self.slot:
|
relevant = True
|
||||||
if receiving_player_id == self.slot:
|
# not related
|
||||||
msg = f"You found your own {self.item_names[item.item]}"
|
else:
|
||||||
else:
|
relevant = False
|
||||||
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
if relevant:
|
||||||
else:
|
item = args["item"]
|
||||||
if receiving_player_id == sending_player_id:
|
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||||
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
|
||||||
else:
|
|
||||||
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
|
||||||
f"{receiving_player_name}"
|
|
||||||
self._set_message(msg, item.item)
|
self._set_message(msg, item.item)
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import Utils
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||||
|
|
||||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||||
get_base_parser
|
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||||
|
|
||||||
@@ -400,6 +399,7 @@ if __name__ == '__main__':
|
|||||||
"Refer to Factorio --help for those.")
|
"Refer to Factorio --help for those.")
|
||||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||||
|
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||||
|
|
||||||
args, rest = parser.parse_known_args()
|
args, rest = parser.parse_known_args()
|
||||||
colorama.init()
|
colorama.init()
|
||||||
@@ -410,6 +410,9 @@ if __name__ == '__main__':
|
|||||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||||
options = Utils.get_options()
|
options = Utils.get_options()
|
||||||
executable = options["factorio_options"]["executable"]
|
executable = options["factorio_options"]["executable"]
|
||||||
|
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||||
|
if server_settings:
|
||||||
|
server_settings = os.path.abspath(server_settings)
|
||||||
|
|
||||||
if not os.path.exists(os.path.dirname(executable)):
|
if not os.path.exists(os.path.dirname(executable)):
|
||||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||||
@@ -421,7 +424,10 @@ if __name__ == '__main__':
|
|||||||
else:
|
else:
|
||||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||||
|
|
||||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
if server_settings and os.path.isfile(server_settings):
|
||||||
|
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||||
|
else:
|
||||||
|
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
162
Fill.py
162
Fill.py
@@ -42,8 +42,16 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
|
|
||||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||||
|
|
||||||
for item_to_place in items_to_place:
|
while items_to_place:
|
||||||
|
# if we have run out of locations to fill,break out of this loop
|
||||||
|
if not locations:
|
||||||
|
unplaced_items += items_to_place
|
||||||
|
break
|
||||||
|
item_to_place = items_to_place.pop(0)
|
||||||
|
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if world.accessibility[item_to_place.player] == 'minimal':
|
if world.accessibility[item_to_place.player] == 'minimal':
|
||||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
@@ -54,7 +62,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
for i, location in enumerate(locations):
|
for i, location in enumerate(locations):
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
||||||
# poping by index is faster than removing by content,
|
# popping by index is faster than removing by content,
|
||||||
spot_to_fill = locations.pop(i)
|
spot_to_fill = locations.pop(i)
|
||||||
# skipping a scan for the element
|
# skipping a scan for the element
|
||||||
break
|
break
|
||||||
@@ -128,33 +136,98 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_fill(world: MultiWorld,
|
||||||
|
locations: typing.List[Location],
|
||||||
|
itempool: typing.List[Item]) -> None:
|
||||||
|
unplaced_items: typing.List[Item] = []
|
||||||
|
placements: typing.List[Location] = []
|
||||||
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
|
while locations and itempool:
|
||||||
|
item_to_place = itempool.pop()
|
||||||
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
|
for i, location in enumerate(locations):
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# popping by index is faster than removing by content,
|
||||||
|
spot_to_fill = locations.pop(i)
|
||||||
|
# skipping a scan for the element
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
# we filled all reachable spots.
|
||||||
|
# try swapping this item with previously placed items
|
||||||
|
|
||||||
|
for (i, location) in enumerate(placements):
|
||||||
|
placed_item = location.item
|
||||||
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
|
# number of times we will swap an individual item to prevent this
|
||||||
|
|
||||||
|
if swapped_items[placed_item.player,
|
||||||
|
placed_item.name] > 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
location.item = None
|
||||||
|
placed_item.location = None
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
|
swapped_items[placed_item.player,
|
||||||
|
placed_item.name] += 1
|
||||||
|
|
||||||
|
itempool.append(placed_item)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
# Item can't be placed here, restore original item
|
||||||
|
location.item = placed_item
|
||||||
|
placed_item.location = location
|
||||||
|
|
||||||
|
if spot_to_fill is None:
|
||||||
|
# Can't place this item, move on to the next
|
||||||
|
unplaced_items.append(item_to_place)
|
||||||
|
continue
|
||||||
|
|
||||||
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
|
placements.append(spot_to_fill)
|
||||||
|
|
||||||
|
if unplaced_items and locations:
|
||||||
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
|
|
||||||
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def fast_fill(world: MultiWorld,
|
||||||
|
item_pool: typing.List[Item],
|
||||||
|
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||||
|
placing = min(len(item_pool), len(fill_locations))
|
||||||
|
for item, location in zip(item_pool, fill_locations):
|
||||||
|
world.push_item(location, item, False)
|
||||||
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||||
fill_locations = sorted(world.get_unfilled_locations())
|
fill_locations = sorted(world.get_unfilled_locations())
|
||||||
world.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
|
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
itempool = sorted(world.itempool)
|
itempool = sorted(world.itempool)
|
||||||
world.random.shuffle(itempool)
|
world.random.shuffle(itempool)
|
||||||
progitempool: typing.List[Item] = []
|
progitempool: typing.List[Item] = []
|
||||||
nonexcludeditempool: typing.List[Item] = []
|
usefulitempool: typing.List[Item] = []
|
||||||
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
filleritempool: typing.List[Item] = []
|
||||||
nonlocalrestitempool: typing.List[Item] = []
|
|
||||||
restitempool: typing.List[Item] = []
|
|
||||||
|
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
progitempool.append(item)
|
progitempool.append(item)
|
||||||
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
elif item.useful:
|
||||||
nonexcludeditempool.append(item)
|
usefulitempool.append(item)
|
||||||
elif item.name in world.local_items[item.player].value:
|
|
||||||
localrestitempool[item.player].append(item)
|
|
||||||
elif item.name in world.non_local_items[item.player].value:
|
|
||||||
nonlocalrestitempool.append(item)
|
|
||||||
else:
|
else:
|
||||||
restitempool.append(item)
|
filleritempool.append(item)
|
||||||
|
|
||||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
|
||||||
|
|
||||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||||
loc_type: [] for loc_type in LocationProgressType}
|
loc_type: [] for loc_type in LocationProgressType}
|
||||||
@@ -176,50 +249,16 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
raise FillError(
|
raise FillError(
|
||||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||||
|
|
||||||
if nonexcludeditempool:
|
remaining_fill(world, excludedlocations, filleritempool)
|
||||||
world.random.shuffle(defaultlocations)
|
if excludedlocations:
|
||||||
# needs logical fill to not conflict with local items
|
raise FillError(
|
||||||
fill_restrictive(
|
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
||||||
world, world.state, defaultlocations, nonexcludeditempool)
|
|
||||||
if nonexcludeditempool:
|
|
||||||
raise FillError(
|
|
||||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
|
||||||
|
|
||||||
defaultlocations = defaultlocations + excludedlocations
|
restitempool = usefulitempool + filleritempool
|
||||||
world.random.shuffle(defaultlocations)
|
|
||||||
|
|
||||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
remaining_fill(world, defaultlocations, restitempool)
|
||||||
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
|
||||||
for location in defaultlocations:
|
|
||||||
local_locations[location.player].append(location)
|
|
||||||
for player_locations in local_locations.values():
|
|
||||||
world.random.shuffle(player_locations)
|
|
||||||
|
|
||||||
for player, items in localrestitempool.items(): # items already shuffled
|
unplaced = restitempool
|
||||||
player_local_locations = local_locations[player]
|
|
||||||
for item_to_place in items:
|
|
||||||
if not player_local_locations:
|
|
||||||
logging.warning(f"Ran out of local locations for player {player}, "
|
|
||||||
f"cannot place {item_to_place}.")
|
|
||||||
break
|
|
||||||
spot_to_fill = player_local_locations.pop()
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
|
||||||
defaultlocations.remove(spot_to_fill)
|
|
||||||
|
|
||||||
for item_to_place in nonlocalrestitempool:
|
|
||||||
for i, location in enumerate(defaultlocations):
|
|
||||||
if location.player != item_to_place.player:
|
|
||||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logging.warning(
|
|
||||||
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
|
|
||||||
|
|
||||||
world.random.shuffle(defaultlocations)
|
|
||||||
|
|
||||||
restitempool, defaultlocations = fast_fill(
|
|
||||||
world, restitempool, defaultlocations)
|
|
||||||
unplaced = progitempool + restitempool
|
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
|
|
||||||
if unplaced or unfilled:
|
if unplaced or unfilled:
|
||||||
@@ -233,15 +272,6 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
logging.info(f'Per-Player counts: {print_data})')
|
logging.info(f'Per-Player counts: {print_data})')
|
||||||
|
|
||||||
|
|
||||||
def fast_fill(world: MultiWorld,
|
|
||||||
item_pool: typing.List[Item],
|
|
||||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
|
||||||
placing = min(len(item_pool), len(fill_locations))
|
|
||||||
for item, location in zip(item_pool, fill_locations):
|
|
||||||
world.push_item(location, item, False)
|
|
||||||
return item_pool[placing:], fill_locations[placing:]
|
|
||||||
|
|
||||||
|
|
||||||
def flood_items(world: MultiWorld) -> None:
|
def flood_items(world: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
world.random.shuffle(world.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
|
|||||||
199
Generate.py
199
Generate.py
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
@@ -5,8 +7,9 @@ import urllib.request
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||||
import os
|
import os
|
||||||
from collections import Counter
|
from collections import Counter, ChainMap
|
||||||
import string
|
import string
|
||||||
|
import enum
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -20,12 +23,47 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
|
|||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
import Options
|
import Options
|
||||||
from worlds.alttp import Bosses
|
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
categories = set(AutoWorldRegister.world_types)
|
|
||||||
|
class PlandoSettings(enum.IntFlag):
|
||||||
|
items = 0b0001
|
||||||
|
connections = 0b0010
|
||||||
|
texts = 0b0100
|
||||||
|
bosses = 0b1000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||||
|
result = cls(0)
|
||||||
|
for part in option_string.split(","):
|
||||||
|
part = part.strip().lower()
|
||||||
|
if part:
|
||||||
|
result = cls._handle_part(part, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||||
|
result = cls(0)
|
||||||
|
for part in option_set:
|
||||||
|
result = cls._handle_part(part, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||||
|
try:
|
||||||
|
part = cls[part]
|
||||||
|
except Exception as e:
|
||||||
|
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||||
|
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||||
|
else:
|
||||||
|
return base | part
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.value:
|
||||||
|
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
@@ -45,11 +83,6 @@ def mystery_argparse():
|
|||||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
|
|
||||||
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
|
|
||||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
|
|
||||||
help="Path to the 1.0 JP SM Baserom.")
|
|
||||||
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
|
|
||||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||||
@@ -64,7 +97,7 @@ def mystery_argparse():
|
|||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||||
return args, options
|
return args, options
|
||||||
|
|
||||||
|
|
||||||
@@ -94,12 +127,14 @@ def main(args=None, callback=ERmain):
|
|||||||
|
|
||||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
|
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
meta_weights = weights_cache[args.meta_file_path][-1]
|
|
||||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||||
del(meta_weights["meta_description"])
|
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||||
|
del(meta_weights["meta_description"])
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||||
if args.samesettings:
|
if args.samesettings:
|
||||||
raise Exception("Cannot mix --samesettings with --meta")
|
raise Exception("Cannot mix --samesettings with --meta")
|
||||||
else:
|
else:
|
||||||
@@ -125,9 +160,9 @@ def main(args=None, callback=ERmain):
|
|||||||
player_files[player_id] = filename
|
player_files[player_id] = filename
|
||||||
player_id += 1
|
player_id += 1
|
||||||
|
|
||||||
args.multi = max(player_id-1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||||
f"{', '.join(args.plando)}")
|
f"{args.plando}")
|
||||||
|
|
||||||
if not weights_cache:
|
if not weights_cache:
|
||||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||||
@@ -142,31 +177,29 @@ def main(args=None, callback=ERmain):
|
|||||||
|
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||||
|
|
||||||
erargs.lttp_rom = args.lttp_rom
|
|
||||||
erargs.sm_rom = args.sm_rom
|
|
||||||
erargs.enemizercli = args.enemizercli
|
|
||||||
|
|
||||||
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.samesettings else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
player_path_cache = {}
|
|
||||||
for player in range(1, args.multi + 1):
|
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
|
||||||
|
|
||||||
if meta_weights:
|
if meta_weights:
|
||||||
for category_name, category_dict in meta_weights.items():
|
for category_name, category_dict in meta_weights.items():
|
||||||
for key in category_dict:
|
for key in category_dict:
|
||||||
option = get_choice(key, category_dict)
|
option = roll_meta_option(key, category_name, category_dict)
|
||||||
if option is not None:
|
if option is not None:
|
||||||
for player, path in player_path_cache.items():
|
for path in weights_cache:
|
||||||
for yaml in weights_cache[path]:
|
for yaml in weights_cache[path]:
|
||||||
if category_name is None:
|
if category_name is None:
|
||||||
yaml[key] = option
|
for category in yaml:
|
||||||
|
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||||
|
yaml[category][key] = option
|
||||||
elif category_name not in yaml:
|
elif category_name not in yaml:
|
||||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||||
else:
|
else:
|
||||||
yaml[category_name][key] = option
|
yaml[category_name][key] = option
|
||||||
|
|
||||||
|
player_path_cache = {}
|
||||||
|
for player in range(1, args.multi + 1):
|
||||||
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
erargs.player_settings = {}
|
erargs.player_settings = {}
|
||||||
|
|
||||||
@@ -303,19 +336,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
|
|||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
|
||||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
|
||||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
|
||||||
Bosses.boss_location_table}
|
|
||||||
|
|
||||||
boss_shuffle_options = {None: 'none',
|
|
||||||
'none': 'none',
|
|
||||||
'basic': 'basic',
|
|
||||||
'full': 'full',
|
|
||||||
'chaos': 'chaos',
|
|
||||||
'singularity': 'singularity'
|
|
||||||
}
|
|
||||||
|
|
||||||
goals = {
|
goals = {
|
||||||
'ganon': 'ganon',
|
'ganon': 'ganon',
|
||||||
'crystals': 'crystals',
|
'crystals': 'crystals',
|
||||||
@@ -348,6 +368,28 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||||
|
if not game:
|
||||||
|
return get_choice(option_key, category_dict)
|
||||||
|
if game in AutoWorldRegister.world_types:
|
||||||
|
game_world = AutoWorldRegister.world_types[game]
|
||||||
|
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
|
||||||
|
if option_key in options:
|
||||||
|
if options[option_key].supports_weighting:
|
||||||
|
return get_choice(option_key, category_dict)
|
||||||
|
return options[option_key]
|
||||||
|
if game == "A Link to the Past": # TODO wow i hate this
|
||||||
|
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||||
|
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||||
|
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||||
|
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||||
|
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||||
|
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||||
|
"random_sprite_on_event"}:
|
||||||
|
return get_choice(option_key, category_dict)
|
||||||
|
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||||
|
|
||||||
|
|
||||||
def roll_linked_options(weights: dict) -> dict:
|
def roll_linked_options(weights: dict) -> dict:
|
||||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||||
for option_set in weights["linked_options"]:
|
for option_set in weights["linked_options"]:
|
||||||
@@ -400,42 +442,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
||||||
if boss_shuffle in boss_shuffle_options:
|
|
||||||
return boss_shuffle_options[boss_shuffle]
|
|
||||||
elif "bosses" in plando_options:
|
|
||||||
options = boss_shuffle.lower().split(";")
|
|
||||||
remainder_shuffle = "none" # vanilla
|
|
||||||
bosses = []
|
|
||||||
for boss in options:
|
|
||||||
if boss in boss_shuffle_options:
|
|
||||||
remainder_shuffle = boss_shuffle_options[boss]
|
|
||||||
elif "-" in boss:
|
|
||||||
loc, boss_name = boss.split("-")
|
|
||||||
if boss_name not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
|
||||||
if loc not in available_boss_locations:
|
|
||||||
raise ValueError(f"Unknown Boss Location {loc}")
|
|
||||||
level = ''
|
|
||||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
||||||
# split off level
|
|
||||||
loc = loc.split(" ")
|
|
||||||
level = f" {loc[-1]}"
|
|
||||||
loc = " ".join(loc[:-1])
|
|
||||||
loc = loc.title().replace("Of", "of")
|
|
||||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
|
||||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
|
||||||
bosses.append(boss)
|
|
||||||
elif boss not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
|
||||||
else:
|
|
||||||
bosses.append(boss)
|
|
||||||
return ";".join(bosses + [remainder_shuffle])
|
|
||||||
else:
|
|
||||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
try:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
@@ -446,13 +453,12 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
if hasattr(player_option, "verify"):
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_key, option(option.default))
|
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",))):
|
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
@@ -465,17 +471,11 @@ def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",
|
|||||||
if tuplize_version(version) > version_tuple:
|
if tuplize_version(version) > version_tuple:
|
||||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||||
f"however generator is of version {__version__}")
|
f"however generator is of version {__version__}")
|
||||||
required_plando_options = requirements.get("plando", "")
|
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||||
if required_plando_options:
|
if required_plando_options not in plando_options:
|
||||||
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
|
|
||||||
required_plando_options -= plando_options
|
|
||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
if len(required_plando_options) == 1:
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
|
f"which is not enabled.")
|
||||||
f"which is not enabled.")
|
|
||||||
else:
|
|
||||||
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
|
|
||||||
f"which are not enabled.")
|
|
||||||
|
|
||||||
ret = argparse.Namespace()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.per_game_common_options:
|
for option_key in Options.per_game_common_options:
|
||||||
@@ -498,18 +498,18 @@ def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",
|
|||||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
if ret.game in AutoWorldRegister.world_types:
|
||||||
for option_key, option in world_type.options.items():
|
for option_key, option in world_type.option_definitions.items():
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
for option_key, option in Options.per_game_common_options.items():
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
# skip setting this option if already set from common_options, defaulting to root option
|
# skip setting this option if already set from common_options, defaulting to root option
|
||||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
if "items" in plando_options:
|
if PlandoSettings.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
# bad hardcoded behavior to make this work for now
|
# bad hardcoded behavior to make this work for now
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
if "connections" in plando_options:
|
if PlandoSettings.connections in plando_options:
|
||||||
options = game_weights.get("plando_connections", [])
|
options = game_weights.get("plando_connections", [])
|
||||||
for placement in options:
|
for placement in options:
|
||||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||||
@@ -555,9 +555,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
|
|
||||||
ret.goal = goals[goal]
|
ret.goal = goals[goal]
|
||||||
|
|
||||||
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
|
|
||||||
# fast ganon + ganon at hole
|
|
||||||
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
|
|
||||||
|
|
||||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||||
|
|
||||||
@@ -589,8 +586,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
|
|
||||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
|
||||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
|
||||||
|
|
||||||
ret.enemy_damage = {None: 'default',
|
ret.enemy_damage = {None: 'default',
|
||||||
'default': 'default',
|
'default': 'default',
|
||||||
@@ -629,7 +624,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||||
|
|
||||||
ret.plando_texts = {}
|
ret.plando_texts = {}
|
||||||
if "texts" in plando_options:
|
if PlandoSettings.texts in plando_options:
|
||||||
tt = TextTable()
|
tt = TextTable()
|
||||||
tt.removeUnwantedText()
|
tt.removeUnwantedText()
|
||||||
options = weights.get("plando_texts", [])
|
options = weights.get("plando_texts", [])
|
||||||
@@ -641,7 +636,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||||
|
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
if "connections" in plando_options:
|
if PlandoSettings.connections in plando_options:
|
||||||
options = weights.get("plando_connections", [])
|
options = weights.get("plando_connections", [])
|
||||||
for placement in options:
|
for placement in options:
|
||||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||||
|
|||||||
22
Launcher.py
22
Launcher.py
@@ -10,16 +10,21 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
|||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from os.path import isfile
|
|
||||||
import sys
|
|
||||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
|
||||||
import subprocess
|
|
||||||
import itertools
|
import itertools
|
||||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
|
||||||
is_windows, is_macos, is_linux
|
|
||||||
from shutil import which
|
|
||||||
import shlex
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
from os.path import isfile
|
||||||
|
from shutil import which
|
||||||
|
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||||
|
is_windows, is_macos, is_linux
|
||||||
|
|
||||||
|
|
||||||
def open_host_yaml():
|
def open_host_yaml():
|
||||||
@@ -65,6 +70,7 @@ def browse_files():
|
|||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyArgumentList
|
||||||
class Type(Enum):
|
class Type(Enum):
|
||||||
TOOL = auto()
|
TOOL = auto()
|
||||||
FUNC = auto() # not a real component
|
FUNC = auto() # not a real component
|
||||||
@@ -126,7 +132,7 @@ components: Iterable[Component] = (
|
|||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||||
# SNI
|
# SNI
|
||||||
Component('SNI Client', 'SNIClient',
|
Component('SNI Client', 'SNIClient',
|
||||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
|
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
# Factorio
|
# Factorio
|
||||||
Component('Factorio Client', 'FactorioClient'),
|
Component('Factorio Client', 'FactorioClient'),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def main():
|
|||||||
|
|
||||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||||
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
|
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
|
||||||
help='Path to an ALttP JAP(1.0) rom to use as a base.')
|
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||||
@@ -83,9 +83,9 @@ def main():
|
|||||||
parser.add_argument('--ow_palettes', default='default',
|
parser.add_argument('--ow_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
parser.add_argument('--link_palettes', default='default',
|
# parser.add_argument('--link_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
# 'sick'])
|
||||||
parser.add_argument('--shield_palettes', default='default',
|
parser.add_argument('--shield_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
@@ -289,7 +289,7 @@ def run_sprite_update():
|
|||||||
else:
|
else:
|
||||||
top.withdraw()
|
top.withdraw()
|
||||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||||
while not done.isSet():
|
while not done.is_set():
|
||||||
task.do_events()
|
task.do_events()
|
||||||
logging.info("Done updating sprites")
|
logging.info("Done updating sprites")
|
||||||
|
|
||||||
@@ -300,6 +300,7 @@ def update_sprites(task, on_finish=None):
|
|||||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||||
os.makedirs(sprite_dir, exist_ok=True)
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
|
|
||||||
def finished():
|
def finished():
|
||||||
task.close_window()
|
task.close_window()
|
||||||
if on_finish:
|
if on_finish:
|
||||||
@@ -751,6 +752,7 @@ class SpriteSelector():
|
|||||||
self.window['pady'] = 5
|
self.window['pady'] = 5
|
||||||
self.spritesPerRow = 32
|
self.spritesPerRow = 32
|
||||||
self.all_sprites = []
|
self.all_sprites = []
|
||||||
|
self.invalid_sprites = []
|
||||||
self.sprite_pool = spritePool
|
self.sprite_pool = spritePool
|
||||||
|
|
||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
@@ -832,6 +834,13 @@ class SpriteSelector():
|
|||||||
self.window.focus()
|
self.window.focus()
|
||||||
tkinter_center_window(self.window)
|
tkinter_center_window(self.window)
|
||||||
|
|
||||||
|
if self.invalid_sprites:
|
||||||
|
invalid = sorted(self.invalid_sprites)
|
||||||
|
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
|
||||||
|
msg = f"{invalid[0]} "
|
||||||
|
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
|
||||||
|
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
|
||||||
|
|
||||||
def remove_from_sprite_pool(self, button, spritename):
|
def remove_from_sprite_pool(self, button, spritename):
|
||||||
self.callback(("remove", spritename))
|
self.callback(("remove", spritename))
|
||||||
self.spritePoolButtons.buttons.remove(button)
|
self.spritePoolButtons.buttons.remove(button)
|
||||||
@@ -896,7 +905,13 @@ class SpriteSelector():
|
|||||||
sprites = []
|
sprites = []
|
||||||
|
|
||||||
for file in os.listdir(path):
|
for file in os.listdir(path):
|
||||||
sprites.append((file, Sprite(os.path.join(path, file))))
|
if file == '.gitignore':
|
||||||
|
continue
|
||||||
|
sprite = Sprite(os.path.join(path, file))
|
||||||
|
if sprite.valid:
|
||||||
|
sprites.append((file, sprite))
|
||||||
|
else:
|
||||||
|
self.invalid_sprites.append(file)
|
||||||
|
|
||||||
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||||
|
|
||||||
@@ -1263,4 +1278,4 @@ class ToolTips(object):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
63
Main.py
63
Main.py
@@ -12,7 +12,7 @@ from typing import Dict, Tuple, Optional, Set
|
|||||||
|
|
||||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||||
from worlds.alttp.Items import item_name_groups
|
from worlds.alttp.Items import item_name_groups
|
||||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
from worlds.alttp.Regions import is_main_entrance
|
||||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||||
from Utils import output_path, get_options, __version__, version_tuple
|
from Utils import output_path, get_options, __version__, version_tuple
|
||||||
@@ -47,7 +47,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.item_functionality = args.item_functionality.copy()
|
world.item_functionality = args.item_functionality.copy()
|
||||||
world.timer = args.timer.copy()
|
world.timer = args.timer.copy()
|
||||||
world.goal = args.goal.copy()
|
world.goal = args.goal.copy()
|
||||||
world.open_pyramid = args.open_pyramid.copy()
|
|
||||||
world.boss_shuffle = args.shufflebosses.copy()
|
world.boss_shuffle = args.shufflebosses.copy()
|
||||||
world.enemy_health = args.enemy_health.copy()
|
world.enemy_health = args.enemy_health.copy()
|
||||||
world.enemy_damage = args.enemy_damage.copy()
|
world.enemy_damage = args.enemy_damage.copy()
|
||||||
@@ -71,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.required_medallions = args.required_medallions.copy()
|
world.required_medallions = args.required_medallions.copy()
|
||||||
world.game = args.game.copy()
|
world.game = args.game.copy()
|
||||||
world.player_name = args.name.copy()
|
world.player_name = args.name.copy()
|
||||||
world.enemizer = args.enemizercli
|
|
||||||
world.sprite = args.sprite.copy()
|
world.sprite = args.sprite.copy()
|
||||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||||
|
|
||||||
@@ -218,9 +216,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger.info("Running Item Plando")
|
logger.info("Running Item Plando")
|
||||||
|
|
||||||
for item in world.itempool:
|
|
||||||
item.world = world
|
|
||||||
|
|
||||||
distribute_planned(world)
|
distribute_planned(world)
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
@@ -254,24 +249,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_file_futures.append(
|
output_file_futures.append(
|
||||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
def get_entrance_to_region(region: Region):
|
|
||||||
for entrance in region.entrances:
|
|
||||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
|
||||||
return entrance
|
|
||||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
|
||||||
return get_entrance_to_region(entrance.parent_region)
|
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
for region in world.regions:
|
|
||||||
if region.player in er_hint_data and region.locations:
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
|
||||||
for location in region.locations:
|
|
||||||
if type(location.address) == int: # skips events and crystals
|
|
||||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
|
||||||
er_hint_data[region.player][location.address] = main_entrance.name
|
|
||||||
|
|
||||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
for player in range(1, world.players + 1)}
|
for player in range(1, world.players + 1)}
|
||||||
@@ -281,22 +261,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
for location in world.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) is int:
|
if type(location.address) is int:
|
||||||
main_entrance = get_entrance_to_region(location.parent_region)
|
|
||||||
if location.game != "A Link to the Past":
|
if location.game != "A Link to the Past":
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif location.parent_region.dungeon:
|
else:
|
||||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
if location.parent_region.dungeon:
|
||||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
checks_in_area[location.player][dungeonname].append(location.address)
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
elif location.parent_region.type == RegionType.LightWorld:
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
elif location.parent_region.type == RegionType.DarkWorld:
|
elif location.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
elif location.parent_region.type == RegionType.DarkWorld:
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
checks_in_area[location.player]["Total"] += 1
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
oldmancaves = []
|
oldmancaves = []
|
||||||
@@ -310,7 +291,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
player = region.player
|
player = region.player
|
||||||
location_id = SHOP_ID_START + total_shop_slots + index
|
location_id = SHOP_ID_START + total_shop_slots + index
|
||||||
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
main_entrance = region.get_connecting_entrance(is_main_entrance)
|
||||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||||
checks_in_area[player]["Light World"].append(location_id)
|
checks_in_area[player]["Light World"].append(location_id)
|
||||||
else:
|
else:
|
||||||
@@ -345,7 +326,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player, world_precollected in world.precollected_items.items()}
|
for player, world_precollected in world.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||||
|
|
||||||
|
|
||||||
for slot in world.player_ids:
|
for slot in world.player_ids:
|
||||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
@@ -364,7 +344,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for location in world.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
"location.address should then also be None"
|
"location.address should then also be None. Location: " \
|
||||||
|
f" {location}"
|
||||||
locations_data[location.player][location.address] = \
|
locations_data[location.player][location.address] = \
|
||||||
location.item.code, location.item.player, location.item.flags
|
location.item.code, location.item.player, location.item.flags
|
||||||
if location.name in world.start_location_hints[location.player]:
|
if location.name in world.start_location_hints[location.player]:
|
||||||
@@ -426,7 +407,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||||
|
|
||||||
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||||
logger.info(f'Creating final archive at {zipfilename}.')
|
logger.info(f"Creating final archive at {zipfilename}")
|
||||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||||
compresslevel=9) as zf:
|
compresslevel=9) as zf:
|
||||||
for file in os.scandir(temp_dir):
|
for file in os.scandir(temp_dir):
|
||||||
|
|||||||
443
MultiServer.py
443
MultiServer.py
@@ -30,17 +30,13 @@ except ImportError:
|
|||||||
OperationalError = ConnectionError
|
OperationalError = ConnectionError
|
||||||
|
|
||||||
import NetUtils
|
import NetUtils
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
|
|
||||||
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
|
|
||||||
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import get_item_name_from_id, get_location_name_from_id, \
|
from Utils import version_tuple, restricted_loads, Version
|
||||||
version_tuple, restricted_loads, Version
|
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType
|
SlotType
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
min_client_version = Version(0, 1, 6)
|
||||||
|
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
|
||||||
colorama.init()
|
colorama.init()
|
||||||
|
|
||||||
# functions callable on storable data on the server by clients
|
# functions callable on storable data on the server by clients
|
||||||
@@ -126,6 +122,12 @@ class Context:
|
|||||||
stored_data: typing.Dict[str, object]
|
stored_data: typing.Dict[str, object]
|
||||||
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
|
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
|
||||||
|
|
||||||
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
|
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||||
|
forced_auto_forfeits: typing.Dict[str, bool]
|
||||||
|
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
||||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||||
@@ -190,8 +192,43 @@ class Context:
|
|||||||
self.stored_data = {}
|
self.stored_data = {}
|
||||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||||
|
|
||||||
# General networking
|
# init empty to satisfy linter, I suppose
|
||||||
|
self.gamespackage = {}
|
||||||
|
self.item_name_groups = {}
|
||||||
|
self.all_item_and_group_names = {}
|
||||||
|
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
||||||
|
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||||
|
|
||||||
|
self._load_game_data()
|
||||||
|
self._init_game_data()
|
||||||
|
|
||||||
|
# Datapackage retrieval
|
||||||
|
def _load_game_data(self):
|
||||||
|
import worlds
|
||||||
|
self.gamespackage = worlds.network_data_package["games"]
|
||||||
|
|
||||||
|
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
|
||||||
|
worlds.AutoWorldRegister.world_types.items()}
|
||||||
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
|
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
|
||||||
|
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||||
|
|
||||||
|
def _init_game_data(self):
|
||||||
|
for game_name, game_package in self.gamespackage.items():
|
||||||
|
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||||
|
self.item_names[item_id] = item_name
|
||||||
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
|
self.location_names[location_id] = location_name
|
||||||
|
self.all_item_and_group_names[game_name] = \
|
||||||
|
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||||
|
|
||||||
|
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
|
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
|
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||||
|
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
|
||||||
|
|
||||||
|
# General networking
|
||||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||||
if not endpoint.socket or not endpoint.socket.open:
|
if not endpoint.socket or not endpoint.socket.open:
|
||||||
return False
|
return False
|
||||||
@@ -256,20 +293,27 @@ class Context:
|
|||||||
|
|
||||||
# text
|
# text
|
||||||
|
|
||||||
def notify_all(self, text):
|
def notify_all(self, text: str):
|
||||||
logging.info("Notice (all): %s" % text)
|
logging.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{"cmd": "Print", "text": text}])
|
broadcast_text_all(self, text)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str):
|
def notify_client(self, client: Client, text: str):
|
||||||
if not client.auth:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
if client.version >= print_command_compatability_threshold:
|
||||||
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
|
||||||
|
else:
|
||||||
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||||
if not client.auth:
|
if not client.auth:
|
||||||
return
|
return
|
||||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
if client.version >= print_command_compatability_threshold:
|
||||||
|
asyncio.create_task(self.send_msgs(client,
|
||||||
|
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
||||||
|
else:
|
||||||
|
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||||
|
|
||||||
# loading
|
# loading
|
||||||
|
|
||||||
@@ -544,12 +588,13 @@ class Context:
|
|||||||
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
|
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
|
||||||
f' has completed their goal.'
|
f' has completed their goal.'
|
||||||
self.notify_all(finished_msg)
|
self.notify_all(finished_msg)
|
||||||
if "auto" in self.forfeit_mode:
|
|
||||||
forfeit_player(self, client.team, client.slot)
|
|
||||||
elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit:
|
|
||||||
forfeit_player(self, client.team, client.slot)
|
|
||||||
if "auto" in self.collect_mode:
|
if "auto" in self.collect_mode:
|
||||||
collect_player(self, client.team, client.slot)
|
collect_player(self, client.team, client.slot)
|
||||||
|
if "auto" in self.forfeit_mode:
|
||||||
|
forfeit_player(self, client.team, client.slot)
|
||||||
|
elif self.forced_auto_forfeits[self.games[client.slot]]:
|
||||||
|
forfeit_player(self, client.team, client.slot)
|
||||||
|
self.save() # save goal completion flag
|
||||||
|
|
||||||
|
|
||||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
||||||
@@ -642,9 +687,10 @@ async def on_client_connected(ctx: Context, client: Client):
|
|||||||
'permissions': get_permissions(ctx),
|
'permissions': get_permissions(ctx),
|
||||||
'hint_cost': ctx.hint_cost,
|
'hint_cost': ctx.hint_cost,
|
||||||
'location_check_points': ctx.location_check_points,
|
'location_check_points': ctx.location_check_points,
|
||||||
'datapackage_version': network_data_package["version"],
|
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
|
||||||
|
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
|
||||||
'datapackage_versions': {game: game_data["version"] for game, game_data
|
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||||
in network_data_package["games"].items()},
|
in ctx.gamespackage.items()},
|
||||||
'seed_name': ctx.seed_name,
|
'seed_name': ctx.seed_name,
|
||||||
'time': time.time(),
|
'time': time.time(),
|
||||||
}])
|
}])
|
||||||
@@ -685,20 +731,37 @@ async def on_client_left(ctx: Context, client: Client):
|
|||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
async def countdown(ctx: Context, timer):
|
async def countdown(ctx: Context, timer: int):
|
||||||
ctx.notify_all(f'[Server]: Starting countdown of {timer}s')
|
broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s")
|
||||||
if ctx.countdown_timer:
|
if ctx.countdown_timer:
|
||||||
ctx.countdown_timer = timer # timer is already running, set it to a different time
|
ctx.countdown_timer = timer # timer is already running, set it to a different time
|
||||||
else:
|
else:
|
||||||
ctx.countdown_timer = timer
|
ctx.countdown_timer = timer
|
||||||
while ctx.countdown_timer > 0:
|
while ctx.countdown_timer > 0:
|
||||||
ctx.notify_all(f'[Server]: {ctx.countdown_timer}')
|
broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}")
|
||||||
ctx.countdown_timer -= 1
|
ctx.countdown_timer -= 1
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
ctx.notify_all(f'[Server]: GO')
|
broadcast_countdown(ctx, 0, f"[Server]: GO")
|
||||||
ctx.countdown_timer = 0
|
ctx.countdown_timer = 0
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}):
|
||||||
|
old_clients, new_clients = [], []
|
||||||
|
|
||||||
|
for teams in ctx.clients.values():
|
||||||
|
for clients in teams.values():
|
||||||
|
for client in clients:
|
||||||
|
new_clients.append(client) if client.version >= print_command_compatability_threshold \
|
||||||
|
else old_clients.append(client)
|
||||||
|
|
||||||
|
ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }])
|
||||||
|
ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_countdown(ctx: Context, timer: int, message: str):
|
||||||
|
broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer})
|
||||||
|
|
||||||
|
|
||||||
def get_players_string(ctx: Context):
|
def get_players_string(ctx: Context):
|
||||||
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
|
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
|
||||||
|
|
||||||
@@ -720,16 +783,16 @@ def get_players_string(ctx: Context):
|
|||||||
return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
|
return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
|
||||||
|
|
||||||
|
|
||||||
def get_status_string(ctx: Context, team: int):
|
def get_status_string(ctx: Context, team: int, tag: str):
|
||||||
text = "Player Status on your team:"
|
text = f"Player Status on team {team}:"
|
||||||
for slot in ctx.locations:
|
for slot in ctx.locations:
|
||||||
connected = len(ctx.clients[team][slot])
|
connected = len(ctx.clients[team][slot])
|
||||||
death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags])
|
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
||||||
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
||||||
death_text = f" {death_link} of which are death link" if connected else ""
|
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
||||||
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
||||||
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
||||||
f"{death_text}{goal_text} {completion_text}"
|
f"{tag_text}{goal_text} {completion_text}"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -766,7 +829,7 @@ def update_checked_locations(ctx: Context, team: int, slot: int):
|
|||||||
def forfeit_player(ctx: Context, team: int, slot: int):
|
def forfeit_player(ctx: Context, team: int, slot: int):
|
||||||
"""register any locations that are in the multidata"""
|
"""register any locations that are in the multidata"""
|
||||||
all_locations = set(ctx.locations[slot])
|
all_locations = set(ctx.locations[slot])
|
||||||
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
|
ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1))
|
||||||
register_location_checks(ctx, team, slot, all_locations)
|
register_location_checks(ctx, team, slot, all_locations)
|
||||||
update_checked_locations(ctx, team, slot)
|
update_checked_locations(ctx, team, slot)
|
||||||
|
|
||||||
@@ -779,7 +842,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
|||||||
if values[1] == slot:
|
if values[1] == slot:
|
||||||
all_locations[source_slot].add(location_id)
|
all_locations[source_slot].add(location_id)
|
||||||
|
|
||||||
ctx.notify_all("%s (Team #%d) has collected" % (ctx.player_names[(team, slot)], team + 1))
|
ctx.notify_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1))
|
||||||
for source_player, location_ids in all_locations.items():
|
for source_player, location_ids in all_locations.items():
|
||||||
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
|
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
|
||||||
update_checked_locations(ctx, team, source_player)
|
update_checked_locations(ctx, team, source_player)
|
||||||
@@ -822,8 +885,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
|
||||||
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
|
ctx.player_names[(team, target_player)], ctx.location_names[location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
info_text = json_format_send_event(new_item, target_player)
|
||||||
ctx.broadcast_team(team, [info_text])
|
ctx.broadcast_team(team, [info_text])
|
||||||
|
|
||||||
@@ -838,13 +901,14 @@ 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: str) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
||||||
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():
|
||||||
if slot in group:
|
if slot in group:
|
||||||
slots.add(group_id)
|
slots.add(group_id)
|
||||||
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
|
||||||
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, check_data in ctx.locations.items():
|
for finding_player, check_data in ctx.locations.items():
|
||||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||||
if receiving_player in slots and item_id == seeked_item_id:
|
if receiving_player in slots and item_id == seeked_item_id:
|
||||||
@@ -857,7 +921,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
|
|||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||||
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
||||||
|
|
||||||
|
|
||||||
@@ -874,8 +938,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
|||||||
|
|
||||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||||
f"{lookup_any_item_id_to_name[hint.item]} is " \
|
f"{ctx.item_names[hint.item]} is " \
|
||||||
f"at {get_location_name_from_id(hint.location)} " \
|
f"at {ctx.location_names[hint.location]} " \
|
||||||
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
f"in {ctx.player_names[team, hint.finding_player]}'s World"
|
||||||
|
|
||||||
if hint.entrance:
|
if hint.entrance:
|
||||||
@@ -1106,20 +1170,26 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return self.ctx.commandprocessor(command)
|
return self.ctx.commandprocessor(command)
|
||||||
|
|
||||||
def _cmd_players(self) -> bool:
|
def _cmd_players(self) -> bool:
|
||||||
"""Get information about connected and missing players"""
|
"""Get information about connected and missing players."""
|
||||||
if len(self.ctx.player_names) < 10:
|
if len(self.ctx.player_names) < 10:
|
||||||
self.ctx.notify_all(get_players_string(self.ctx))
|
self.ctx.notify_all(get_players_string(self.ctx))
|
||||||
else:
|
else:
|
||||||
self.output(get_players_string(self.ctx))
|
self.output(get_players_string(self.ctx))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_status(self) -> bool:
|
def _cmd_status(self, tag:str="") -> bool:
|
||||||
"""Get status information about your team."""
|
"""Get status information about your team.
|
||||||
self.output(get_status_string(self.ctx, self.client.team))
|
Optionally mention a Tag name and get information on who has that Tag.
|
||||||
|
For example: DeathLink or EnergyLink."""
|
||||||
|
self.output(get_status_string(self.ctx, self.client.team, tag))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _cmd_release(self) -> bool:
|
||||||
|
"""Sends remaining items in your world to their recipients."""
|
||||||
|
return self._cmd_forfeit()
|
||||||
|
|
||||||
def _cmd_forfeit(self) -> bool:
|
def _cmd_forfeit(self) -> bool:
|
||||||
"""Surrender and send your remaining items out to their recipients"""
|
"""Surrender and send your remaining items out to their recipients. Use release in the future."""
|
||||||
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
|
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
|
||||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||||
return True
|
return True
|
||||||
@@ -1127,8 +1197,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||||
return True
|
return True
|
||||||
elif "disabled" in self.ctx.forfeit_mode:
|
elif "disabled" in self.ctx.forfeit_mode:
|
||||||
self.output(
|
self.output("Sorry, client item releasing has been disabled on this server. "
|
||||||
"Sorry, client forfeiting has been disabled on this server. You can ask the server admin for a /forfeit")
|
"You can ask the server admin for a /release")
|
||||||
return False
|
return False
|
||||||
else: # is auto or goal
|
else: # is auto or goal
|
||||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||||
@@ -1136,8 +1206,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.output(
|
self.output(
|
||||||
"Sorry, client forfeiting requires you to have beaten the game on this server."
|
"Sorry, client item releasing requires you to have beaten the game on this server."
|
||||||
" You can ask the server admin for a /forfeit")
|
" You can ask the server admin for a /release")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_collect(self) -> bool:
|
def _cmd_collect(self) -> bool:
|
||||||
@@ -1164,7 +1234,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for item_id in remaining_item_ids))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
@@ -1177,7 +1247,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||||
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
|
||||||
if remaining_item_ids:
|
if remaining_item_ids:
|
||||||
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
|
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
|
||||||
for item_id in remaining_item_ids))
|
for item_id in remaining_item_ids))
|
||||||
else:
|
else:
|
||||||
self.output("No remaining items found.")
|
self.output("No remaining items found.")
|
||||||
@@ -1193,7 +1263,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations]
|
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
|
||||||
texts.append(f"Found {len(locations)} missing location checks")
|
texts.append(f"Found {len(locations)} missing location checks")
|
||||||
self.ctx.notify_client_multiple(self.client, texts)
|
self.ctx.notify_client_multiple(self.client, texts)
|
||||||
else:
|
else:
|
||||||
@@ -1206,7 +1276,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||||
|
|
||||||
if locations:
|
if locations:
|
||||||
texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations]
|
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
|
||||||
texts.append(f"Found {len(locations)} done location checks")
|
texts.append(f"Found {len(locations)} done location checks")
|
||||||
self.ctx.notify_client_multiple(self.client, texts)
|
self.ctx.notify_client_multiple(self.client, texts)
|
||||||
else:
|
else:
|
||||||
@@ -1235,11 +1305,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def _cmd_getitem(self, item_name: str) -> bool:
|
def _cmd_getitem(self, item_name: str) -> bool:
|
||||||
"""Cheat in an item, if it is enabled on this server"""
|
"""Cheat in an item, if it is enabled on this server"""
|
||||||
if self.ctx.item_cheat:
|
if self.ctx.item_cheat:
|
||||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot])
|
||||||
item_name, usable, response = get_intended_text(item_name,
|
item_name, usable, response = get_intended_text(
|
||||||
world.item_names)
|
item_name,
|
||||||
|
names
|
||||||
|
)
|
||||||
if usable:
|
if usable:
|
||||||
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
|
new_item = NetworkItem(names[item_name], -1, self.client.slot)
|
||||||
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
|
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
|
||||||
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
|
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
|
||||||
self.ctx.notify_all(
|
self.ctx.notify_all(
|
||||||
@@ -1264,86 +1336,112 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||||
f"You have {points_available} points.")
|
f"You have {points_available} points.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
elif input_text.isnumeric():
|
||||||
|
game = self.ctx.games[self.client.slot]
|
||||||
|
hint_id = int(input_text)
|
||||||
|
hint_name = self.ctx.item_names[hint_id] \
|
||||||
|
if not for_location and hint_id in self.ctx.item_names \
|
||||||
|
else self.ctx.location_names[hint_id] \
|
||||||
|
if for_location and hint_id in self.ctx.location_names \
|
||||||
|
else None
|
||||||
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
|
hints = []
|
||||||
|
elif not for_location:
|
||||||
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
else:
|
||||||
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
game = self.ctx.games[self.client.slot]
|
||||||
names = world.location_names if for_location else world.all_item_and_group_names
|
if game not in self.ctx.all_item_and_group_names:
|
||||||
hint_name, usable, response = get_intended_text(input_text,
|
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
|
||||||
names)
|
return False
|
||||||
|
names = self.ctx.location_names_for_game(game) \
|
||||||
|
if for_location else \
|
||||||
|
self.ctx.all_item_and_group_names[game]
|
||||||
|
hint_name, usable, response = get_intended_text(input_text, names)
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if hint_name in world.hint_blacklist:
|
if hint_name in self.ctx.non_hintable_names[game]:
|
||||||
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 and hint_name in world.item_name_groups: # item group name
|
elif not for_location and hint_name in self.ctx.item_name_groups[game]: # item group name
|
||||||
hints = []
|
hints = []
|
||||||
for item in world.item_name_groups[hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item in world.item_name_to_id: # 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))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||||
elif not for_location and hint_name in world.item_names: # 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)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
|
||||||
if hints:
|
|
||||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
|
||||||
old_hints = set(hints) - new_hints
|
|
||||||
if old_hints:
|
|
||||||
notify_hints(self.ctx, self.client.team, list(old_hints))
|
|
||||||
if not new_hints:
|
|
||||||
self.output("Hint was previously used, no points deducted.")
|
|
||||||
if new_hints:
|
|
||||||
found_hints = [hint for hint in new_hints if hint.found]
|
|
||||||
not_found_hints = [hint for hint in new_hints if not hint.found]
|
|
||||||
|
|
||||||
if not not_found_hints: # everything's been found, no need to pay
|
|
||||||
can_pay = 1000
|
|
||||||
elif cost:
|
|
||||||
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
|
||||||
else:
|
|
||||||
can_pay = 1000
|
|
||||||
|
|
||||||
self.ctx.random.shuffle(not_found_hints)
|
|
||||||
|
|
||||||
hints = found_hints
|
|
||||||
while can_pay > 0:
|
|
||||||
if not not_found_hints:
|
|
||||||
break
|
|
||||||
hint = not_found_hints.pop()
|
|
||||||
hints.append(hint)
|
|
||||||
can_pay -= 1
|
|
||||||
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
|
||||||
|
|
||||||
if not_found_hints:
|
|
||||||
if hints and cost and int((points_available // cost) == 0):
|
|
||||||
self.output(
|
|
||||||
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
|
||||||
f" You have {points_available} and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
elif hints:
|
|
||||||
self.output(
|
|
||||||
"There may be more hintables, you can rerun the command to find more.")
|
|
||||||
else:
|
|
||||||
self.output(f"You can't afford the hint. "
|
|
||||||
f"You have {points_available} points and need at least "
|
|
||||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
|
||||||
notify_hints(self.ctx, self.client.team, hints)
|
|
||||||
self.ctx.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.output("Nothing found. Item/Location may not exist.")
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if hints:
|
||||||
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||||
|
old_hints = set(hints) - new_hints
|
||||||
|
if old_hints:
|
||||||
|
notify_hints(self.ctx, self.client.team, list(old_hints))
|
||||||
|
if not new_hints:
|
||||||
|
self.output("Hint was previously used, no points deducted.")
|
||||||
|
if new_hints:
|
||||||
|
found_hints = [hint for hint in new_hints if hint.found]
|
||||||
|
not_found_hints = [hint for hint in new_hints if not hint.found]
|
||||||
|
|
||||||
|
if not not_found_hints: # everything's been found, no need to pay
|
||||||
|
can_pay = 1000
|
||||||
|
elif cost:
|
||||||
|
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
||||||
|
else:
|
||||||
|
can_pay = 1000
|
||||||
|
|
||||||
|
self.ctx.random.shuffle(not_found_hints)
|
||||||
|
# By popular vote, make hints prefer non-local placements
|
||||||
|
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
|
||||||
|
|
||||||
|
hints = found_hints
|
||||||
|
while can_pay > 0:
|
||||||
|
if not not_found_hints:
|
||||||
|
break
|
||||||
|
hint = not_found_hints.pop()
|
||||||
|
hints.append(hint)
|
||||||
|
can_pay -= 1
|
||||||
|
self.ctx.hints_used[self.client.team, self.client.slot] += 1
|
||||||
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
|
|
||||||
|
if not_found_hints:
|
||||||
|
if hints and cost and int((points_available // cost) == 0):
|
||||||
|
self.output(
|
||||||
|
f"There may be more hintables, however, you cannot afford to pay for any more. "
|
||||||
|
f" You have {points_available} and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
elif hints:
|
||||||
|
self.output(
|
||||||
|
"There may be more hintables, you can rerun the command to find more.")
|
||||||
|
else:
|
||||||
|
self.output(f"You can't afford the hint. "
|
||||||
|
f"You have {points_available} points and need at least "
|
||||||
|
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||||
|
notify_hints(self.ctx, self.client.team, hints)
|
||||||
|
self.ctx.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.output("Nothing found. Item/Location may not exist.")
|
||||||
|
return False
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_hint(self, item: str = "") -> bool:
|
def _cmd_hint(self, item_name: str = "") -> bool:
|
||||||
"""Use !hint {item_name},
|
"""Use !hint {item_name},
|
||||||
for example !hint Lamp to get a spoiler peek for that item.
|
for example !hint Lamp to get a spoiler peek for that item.
|
||||||
If hint costs are on, this will only give you one new result,
|
If hint costs are on, this will only give you one new result,
|
||||||
you can rerun the command to get more in that case."""
|
you can rerun the command to get more in that case."""
|
||||||
return self.get_hints(item)
|
return self.get_hints(item_name)
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_hint_location(self, location: str = "") -> bool:
|
def _cmd_hint_location(self, location: str = "") -> bool:
|
||||||
@@ -1469,23 +1567,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
elif cmd == "GetDataPackage":
|
elif cmd == "GetDataPackage":
|
||||||
exclusions = args.get("exclusions", [])
|
exclusions = args.get("exclusions", [])
|
||||||
if "games" in args:
|
if "games" in args:
|
||||||
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||||
if name in set(args.get("games", []))}
|
if name in set(args.get("games", []))}
|
||||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
"data": {"games": games}}])
|
"data": {"games": games}}])
|
||||||
# TODO: remove exclusions behaviour around 0.5.0
|
# TODO: remove exclusions behaviour around 0.5.0
|
||||||
elif exclusions:
|
elif exclusions:
|
||||||
exclusions = set(exclusions)
|
exclusions = set(exclusions)
|
||||||
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||||
if name not in exclusions}
|
if name not in exclusions}
|
||||||
package = network_data_package.copy()
|
|
||||||
package["games"] = games
|
package = {"games": games}
|
||||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
"data": package}])
|
"data": package}])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||||
"data": network_data_package}])
|
"data": {"games": ctx.gamespackage}}])
|
||||||
|
|
||||||
elif client.auth:
|
elif client.auth:
|
||||||
if cmd == "ConnectUpdate":
|
if cmd == "ConnectUpdate":
|
||||||
@@ -1541,7 +1639,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
create_as_hint: int = int(args.get("create_as_hint", 0))
|
create_as_hint: int = int(args.get("create_as_hint", 0))
|
||||||
hints = []
|
hints = []
|
||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
if type(location) is not int:
|
||||||
await ctx.send_msgs(client,
|
await ctx.send_msgs(client,
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
||||||
"original_cmd": cmd}])
|
"original_cmd": cmd}])
|
||||||
@@ -1653,6 +1751,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(get_players_string(self.ctx))
|
self.output(get_players_string(self.ctx))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _cmd_status(self, tag: str = "") -> bool:
|
||||||
|
"""Get status information about teams.
|
||||||
|
Optionally mention a Tag name and get information on who has that Tag.
|
||||||
|
For example: DeathLink or EnergyLink."""
|
||||||
|
for team in self.ctx.clients:
|
||||||
|
self.output(get_status_string(self.ctx, team, tag))
|
||||||
|
return True
|
||||||
|
|
||||||
def _cmd_exit(self) -> bool:
|
def _cmd_exit(self) -> bool:
|
||||||
"""Shutdown the server"""
|
"""Shutdown the server"""
|
||||||
asyncio.create_task(self.ctx.server.ws_server._close())
|
asyncio.create_task(self.ctx.server.ws_server._close())
|
||||||
@@ -1698,43 +1804,48 @@ 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
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_release(self, player_name: str) -> bool:
|
||||||
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
|
return self._cmd_forfeit(player_name)
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
def _cmd_forfeit(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."""
|
||||||
seeked_player = player_name.lower()
|
seeked_player = player_name.lower()
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
if name.lower() == seeked_player:
|
if name.lower() == seeked_player:
|
||||||
forfeit_player(self.ctx, team, slot)
|
forfeit_player(self.ctx, team, slot)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to forfeit")
|
self.output(f"Could not find player {player_name} to release")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||||
"""Allow the specified player to use the !forfeit command"""
|
"""Allow the specified player to use the !release command."""
|
||||||
seeked_player = player_name.lower()
|
seeked_player = player_name.lower()
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
if name.lower() == seeked_player:
|
if name.lower() == seeked_player:
|
||||||
self.ctx.allow_forfeits[(team, slot)] = True
|
self.ctx.allow_forfeits[(team, slot)] = True
|
||||||
self.output(f"Player {player_name} is now allowed to use the !forfeit command at any time.")
|
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to allow the !forfeit command for.")
|
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||||
""""Disallow the specified player from using the !forfeit command"""
|
""""Disallow the specified player from using the !release command."""
|
||||||
seeked_player = player_name.lower()
|
seeked_player = player_name.lower()
|
||||||
for (team, slot), name in self.ctx.player_names.items():
|
for (team, slot), name in self.ctx.player_names.items():
|
||||||
if name.lower() == seeked_player:
|
if name.lower() == seeked_player:
|
||||||
self.ctx.allow_forfeits[(team, slot)] = False
|
self.ctx.allow_forfeits[(team, slot)] = False
|
||||||
self.output(
|
self.output(
|
||||||
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
|
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
|
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
|
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
|
||||||
@@ -1742,18 +1853,18 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
item = " ".join(item_name)
|
item_name = " ".join(item_name)
|
||||||
world = proxy_worlds[self.ctx.games[slot]]
|
names = self.ctx.item_names_for_game(self.ctx.games[slot])
|
||||||
item, usable, response = get_intended_text(item, world.item_names)
|
item_name, usable, response = get_intended_text(item_name, names)
|
||||||
if usable:
|
if usable:
|
||||||
amount: int = int(amount)
|
amount: int = int(amount)
|
||||||
new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))]
|
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
|
||||||
send_items_to(self.ctx, team, slot, *new_items)
|
send_items_to(self.ctx, team, slot, *new_items)
|
||||||
|
|
||||||
send_new_items(self.ctx)
|
send_new_items(self.ctx)
|
||||||
self.ctx.notify_all(
|
self.ctx.notify_all(
|
||||||
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
|
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
|
||||||
f'"{item}" to {self.ctx.get_aliased_name(team, slot)}')
|
f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}')
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -1766,21 +1877,29 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
"""Sends an item to the specified player"""
|
"""Sends an item to the specified player"""
|
||||||
return self._cmd_send_multiple(1, player_name, *item_name)
|
return self._cmd_send_multiple(1, player_name, *item_name)
|
||||||
|
|
||||||
def _cmd_hint(self, player_name: str, *item: str) -> bool:
|
def _cmd_hint(self, player_name: str, *item_name: str) -> bool:
|
||||||
"""Send out a hint for a player's item to their team"""
|
"""Send out a hint for a player's item to their team"""
|
||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
item = " ".join(item)
|
game = self.ctx.games[slot]
|
||||||
world = proxy_worlds[self.ctx.games[slot]]
|
full_name = " ".join(item_name)
|
||||||
item, usable, response = get_intended_text(item, world.all_item_and_group_names)
|
|
||||||
|
if full_name.isnumeric():
|
||||||
|
item, usable, response = int(full_name), True, None
|
||||||
|
elif game in self.ctx.all_item_and_group_names:
|
||||||
|
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
|
||||||
|
else:
|
||||||
|
self.output("Can't look up item for unknown game. Hint for ID instead.")
|
||||||
|
return False
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if item in world.item_name_groups:
|
if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for item in world.item_name_groups[item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item in world.item_name_to_id: # 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))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||||
else: # item name
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
@@ -1797,16 +1916,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(response)
|
self.output(response)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_hint_location(self, player_name: str, *location: str) -> bool:
|
def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
|
||||||
"""Send out a hint for a player's location to their team"""
|
"""Send out a hint for a player's location to their team"""
|
||||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||||
if usable:
|
if usable:
|
||||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||||
item = " ".join(location)
|
game = self.ctx.games[slot]
|
||||||
world = proxy_worlds[self.ctx.games[slot]]
|
full_name = " ".join(location_name)
|
||||||
item, usable, response = get_intended_text(item, world.location_names)
|
|
||||||
|
if full_name.isnumeric():
|
||||||
|
location, usable, response = int(full_name), True, None
|
||||||
|
elif self.ctx.location_names_for_game(game) is not None:
|
||||||
|
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
|
||||||
|
else:
|
||||||
|
self.output("Can't look up location for unknown game. Hint for ID instead.")
|
||||||
|
return False
|
||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, item)
|
if isinstance(location, int):
|
||||||
|
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||||
|
else:
|
||||||
|
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||||
if hints:
|
if hints:
|
||||||
notify_hints(self.ctx, team, hints)
|
notify_hints(self.ctx, team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -1956,15 +2086,28 @@ async def main(args: argparse.Namespace):
|
|||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
try:
|
if not data_filename:
|
||||||
if not data_filename:
|
try:
|
||||||
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
||||||
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
|
||||||
|
if not isinstance(e, ImportError):
|
||||||
|
logging.error(f"Failed to load tkinter ({e})")
|
||||||
|
logging.info("Pass a multidata filename on command line to run headless.")
|
||||||
|
exit(1)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not data_filename:
|
||||||
|
logging.info("No file selected. Exiting.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
ctx.load(data_filename, args.use_embedded_options)
|
ctx.load(data_filename, args.use_embedded_options)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception('Failed to read multiworld data (%s)' % e)
|
logging.exception(f"Failed to read multiworld data ({e})")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
ctx.init_save(not args.disable_save)
|
ctx.init_save(not args.disable_save)
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
|||||||
|
|
||||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||||
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||||
|
|
||||||
|
|
||||||
def color_code(*args):
|
def color_code(*args):
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
|||||||
|
|
||||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||||
|
|
||||||
script_version: int = 1
|
script_version: int = 2
|
||||||
|
|
||||||
def get_item_value(ap_id):
|
def get_item_value(ap_id):
|
||||||
return ap_id - 66000
|
return ap_id - 66000
|
||||||
@@ -186,7 +186,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||||
data_decoded = json.loads(data.decode())
|
data_decoded = json.loads(data.decode())
|
||||||
reported_version = data_decoded.get('scriptVersion', 0)
|
reported_version = data_decoded.get('scriptVersion', 0)
|
||||||
if reported_version == script_version:
|
if reported_version >= script_version:
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||||
|
|||||||
117
Options.py
117
Options.py
@@ -26,15 +26,31 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
|
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||||
|
|
||||||
|
# auto-alias Off and On being parsed as True and False
|
||||||
|
if "off" in options:
|
||||||
|
options["false"] = options["off"]
|
||||||
|
if "on" in options:
|
||||||
|
options["true"] = options["on"]
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
|
if "verify" not in attrs:
|
||||||
|
# not overridden by class -> look up bases
|
||||||
|
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
||||||
|
if len(verifiers) > 1: # verify multiple bases/mixins
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
for f in verifiers:
|
||||||
|
f(self, *args, **kwargs)
|
||||||
|
attrs["verify"] = verify
|
||||||
|
else:
|
||||||
|
assert verifiers, "class Option is supposed to implement def verify"
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
@@ -112,6 +128,41 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from Generate import PlandoSettings
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
|
||||||
|
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FreeText(Option):
|
||||||
|
"""Text option that allows users to enter strings.
|
||||||
|
Needs to be validated by the world or option definition."""
|
||||||
|
|
||||||
|
def __init__(self, value: str):
|
||||||
|
assert isinstance(value, str), "value of FreeText must be a string"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> FreeText:
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> FreeText:
|
||||||
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral):
|
class NumericOption(Option[int], numbers.Integral):
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
@@ -298,7 +349,7 @@ class Toggle(NumericOption):
|
|||||||
if type(data) == str:
|
if type(data) == str:
|
||||||
return cls.from_text(data)
|
return cls.from_text(data)
|
||||||
else:
|
else:
|
||||||
return cls(data)
|
return cls(int(data))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_option_name(cls, value):
|
def get_option_name(cls, value):
|
||||||
@@ -368,6 +419,53 @@ class Choice(NumericOption):
|
|||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
|
class TextChoice(Choice):
|
||||||
|
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
|
self.value = value
|
||||||
|
super(TextChoice, self).__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
if isinstance(self.value, str):
|
||||||
|
return self.value
|
||||||
|
else:
|
||||||
|
return self.name_lookup[self.value]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> TextChoice:
|
||||||
|
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
||||||
|
return cls(random.choice(list(cls.name_lookup)))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name.lower() == text.lower():
|
||||||
|
return cls(value)
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return cls.name_lookup[value]
|
||||||
|
|
||||||
|
def __eq__(self, other: typing.Any):
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return other.value == self.value
|
||||||
|
elif isinstance(other, str):
|
||||||
|
if other in self.options:
|
||||||
|
return other == self.current_key
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, int):
|
||||||
|
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, bool):
|
||||||
|
return other == bool(self.value)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
@@ -385,7 +483,7 @@ class Range(NumericOption):
|
|||||||
if text.startswith("random"):
|
if text.startswith("random"):
|
||||||
return cls.weighted_range(text)
|
return cls.weighted_range(text)
|
||||||
elif text == "default" and hasattr(cls, "default"):
|
elif text == "default" and hasattr(cls, "default"):
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
elif text == "high":
|
elif text == "high":
|
||||||
return cls(cls.range_end)
|
return cls(cls.range_end)
|
||||||
elif text == "low":
|
elif text == "low":
|
||||||
@@ -396,7 +494,7 @@ class Range(NumericOption):
|
|||||||
and text in ("true", "false"):
|
and text in ("true", "false"):
|
||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
else: # "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
return cls(int(text))
|
||||||
@@ -507,7 +605,7 @@ class VerifyKeys:
|
|||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls.valid_keys}.")
|
f"Allowed keys: {cls.valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
@@ -600,10 +698,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if type(data) == list:
|
if isinstance(data, (list, set, frozenset)):
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
|
||||||
elif type(data) == set:
|
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -732,8 +827,8 @@ class ItemLinks(OptionList):
|
|||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world, player_name: str, plando_options) -> None:
|
||||||
super(ItemLinks, self).verify(world)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
|
|||||||
37
Patch.py
37
Patch.py
@@ -17,7 +17,7 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
current_patch_version = 4
|
current_patch_version = 5
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(type):
|
class AutoPatchRegister(type):
|
||||||
@@ -128,6 +128,7 @@ class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
|||||||
manifest = super(APDeltaPatch, self).get_manifest()
|
manifest = super(APDeltaPatch, self).get_manifest()
|
||||||
manifest["base_checksum"] = self.hash
|
manifest["base_checksum"] = self.hash
|
||||||
manifest["result_file_ending"] = self.result_file_ending
|
manifest["result_file_ending"] = self.result_file_ending
|
||||||
|
manifest["patch_file_ending"] = self.patch_file_ending
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -166,27 +167,31 @@ GAME_ALTTP = "A Link to the Past"
|
|||||||
GAME_SM = "Super Metroid"
|
GAME_SM = "Super Metroid"
|
||||||
GAME_SOE = "Secret of Evermore"
|
GAME_SOE = "Secret of Evermore"
|
||||||
GAME_SMZ3 = "SMZ3"
|
GAME_SMZ3 = "SMZ3"
|
||||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
|
GAME_DKC3 = "Donkey Kong Country 3"
|
||||||
|
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
||||||
|
|
||||||
preferred_endings = {
|
preferred_endings = {
|
||||||
GAME_ALTTP: "apbp",
|
GAME_ALTTP: "apbp",
|
||||||
GAME_SM: "apm3",
|
GAME_SM: "apm3",
|
||||||
GAME_SOE: "apsoe",
|
GAME_SOE: "apsoe",
|
||||||
GAME_SMZ3: "apsmz"
|
GAME_SMZ3: "apsmz",
|
||||||
|
GAME_DKC3: "apdkc3"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||||
if game == GAME_ALTTP:
|
if game == GAME_ALTTP:
|
||||||
from worlds.alttp.Rom import JAP10HASH as HASH
|
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||||
elif game == GAME_SM:
|
elif game == GAME_SM:
|
||||||
from worlds.sm.Rom import JAP10HASH as HASH
|
from worlds.sm.Rom import SMJUHASH as HASH
|
||||||
elif game == GAME_SOE:
|
elif game == GAME_SOE:
|
||||||
from worlds.soe.Patch import USHASH as HASH
|
from worlds.soe.Patch import USHASH as HASH
|
||||||
elif game == GAME_SMZ3:
|
elif game == GAME_SMZ3:
|
||||||
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
|
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||||
from worlds.sm.Rom import JAP10HASH as SMHASH
|
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||||
HASH = ALTTPHASH + SMHASH
|
HASH = ALTTPHASH + SMHASH
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import USHASH as HASH
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||||
|
|
||||||
@@ -216,7 +221,10 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
|
|||||||
meta,
|
meta,
|
||||||
game)
|
game)
|
||||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||||
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
|
".apbp" if game == GAME_ALTTP
|
||||||
|
else ".apsmz" if game == GAME_SMZ3
|
||||||
|
else ".apdkc3" if game == GAME_DKC3
|
||||||
|
else ".apm3")
|
||||||
write_lzma(bytes, target)
|
write_lzma(bytes, target)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
@@ -245,6 +253,8 @@ def get_base_rom_data(game: str):
|
|||||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
||||||
elif game == GAME_SMZ3:
|
elif game == GAME_SMZ3:
|
||||||
from worlds.smz3.Rom import get_base_rom_bytes
|
from worlds.smz3.Rom import get_base_rom_bytes
|
||||||
|
elif game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Rom import get_base_rom_bytes
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("Selected game for base rom not found.")
|
raise RuntimeError("Selected game for base rom not found.")
|
||||||
return get_base_rom_bytes()
|
return get_base_rom_bytes()
|
||||||
@@ -389,6 +399,13 @@ if __name__ == "__main__":
|
|||||||
if 'server' in data:
|
if 'server' in data:
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
print(f"Host is {data['server']}")
|
print(f"Host is {data['server']}")
|
||||||
|
elif rom.endswith(".apdkc3"):
|
||||||
|
print(f"Applying patch {rom}")
|
||||||
|
data, target = create_rom_file(rom)
|
||||||
|
print(f"Created rom {target}.")
|
||||||
|
if 'server' in data:
|
||||||
|
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||||
|
print(f"Host is {data['server']}")
|
||||||
|
|
||||||
elif rom.endswith(".zip"):
|
elif rom.endswith(".zip"):
|
||||||
print(f"Updating host in patch files contained in {rom}")
|
print(f"Updating host in patch files contained in {rom}")
|
||||||
@@ -396,7 +413,9 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||||
data = zfr.read(zfinfo)
|
data = zfr.read(zfinfo)
|
||||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
if zfinfo.filename.endswith(".apbp") or \
|
||||||
|
zfinfo.filename.endswith(".apm3") or \
|
||||||
|
zfinfo.filename.endswith(".apdkc3"):
|
||||||
data = update_patch_data(data, server)
|
data = update_patch_data(data, server)
|
||||||
with ziplock:
|
with ziplock:
|
||||||
zfw.writestr(zfinfo, data)
|
zfw.writestr(zfinfo, data)
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -26,6 +26,8 @@ Currently, the following games are supported:
|
|||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
* Starcraft 2: Wings of Liberty
|
* Starcraft 2: Wings of Liberty
|
||||||
|
* Donkey Kong Country 3
|
||||||
|
* Dark Souls 3
|
||||||
|
|
||||||
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
|
||||||
@@ -49,7 +51,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
|
|||||||
## Running Archipelago
|
## Running Archipelago
|
||||||
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||||
|
|
||||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
|
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||||
|
|
||||||
## Related Repositories
|
## Related Repositories
|
||||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||||
@@ -59,26 +61,10 @@ This project makes use of multiple other projects. We wouldn't be here without t
|
|||||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Contributions are welcome. We have a few asks of any new contributors.
|
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
||||||
|
|
||||||
* Ensure that all changes which affect logic are covered by unit tests.
|
|
||||||
* Do not introduce any unit test failures/regressions.
|
|
||||||
|
|
||||||
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
|
||||||
|
|
||||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
|
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||||
|
|
||||||
* Be welcoming and inclusive in tone and language.
|
|
||||||
* Be respectful of others and their abilities.
|
|
||||||
* Show empathy when speaking with others.
|
|
||||||
* Be gracious and accept feedback and constructive criticism.
|
|
||||||
|
|
||||||
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
|
|
||||||
|
|
||||||
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.
|
|
||||||
|
|||||||
160
SNIClient.py
160
SNIClient.py
@@ -15,9 +15,6 @@ import typing
|
|||||||
|
|
||||||
from json import loads, dumps
|
from json import loads, dumps
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from Utils import init_logging, messagebox
|
from Utils import init_logging, messagebox
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -33,7 +30,7 @@ from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
|||||||
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
||||||
import Utils
|
import Utils
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
|
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3
|
||||||
|
|
||||||
snes_logger = logging.getLogger("SNES")
|
snes_logger = logging.getLogger("SNES")
|
||||||
|
|
||||||
@@ -62,7 +59,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
|||||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||||
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
||||||
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
|
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
|
||||||
|
|
||||||
snes_address = self.ctx.snes_address
|
snes_address = self.ctx.snes_address
|
||||||
snes_device_number = -1
|
snes_device_number = -1
|
||||||
@@ -149,8 +146,8 @@ class Context(CommonContext):
|
|||||||
def event_invalid_slot(self):
|
def event_invalid_slot(self):
|
||||||
if self.snes_socket is not None and not self.snes_socket.closed:
|
if self.snes_socket is not None and not self.snes_socket.closed:
|
||||||
asyncio.create_task(self.snes_socket.close())
|
asyncio.create_task(self.snes_socket.close())
|
||||||
raise Exception('Invalid ROM detected, '
|
raise Exception("Invalid ROM detected, "
|
||||||
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
|
"please verify that you have loaded the correct rom and reconnect your snes (/snes)")
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -158,7 +155,7 @@ class Context(CommonContext):
|
|||||||
if self.rom is None:
|
if self.rom is None:
|
||||||
self.awaiting_rom = True
|
self.awaiting_rom = True
|
||||||
snes_logger.info(
|
snes_logger.info(
|
||||||
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
|
"No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)")
|
||||||
return
|
return
|
||||||
self.awaiting_rom = False
|
self.awaiting_rom = False
|
||||||
self.auth = self.rom
|
self.auth = self.rom
|
||||||
@@ -188,7 +185,10 @@ class Context(CommonContext):
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
await super(Context, self).shutdown()
|
await super(Context, self).shutdown()
|
||||||
if self.snes_connect_task:
|
if self.snes_connect_task:
|
||||||
await self.snes_connect_task
|
try:
|
||||||
|
await asyncio.wait_for(self.snes_connect_task, 1)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.snes_connect_task.cancel()
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd in {"Connected", "RoomUpdate"}:
|
if cmd in {"Connected", "RoomUpdate"}:
|
||||||
@@ -251,12 +251,15 @@ async def deathlink_kill_player(ctx: Context):
|
|||||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
|
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
|
||||||
ctx.death_link_allow_survive and health is not None and health > 0):
|
ctx.death_link_allow_survive and health is not None and health > 0):
|
||||||
ctx.death_state = DeathState.dead
|
ctx.death_state = DeathState.dead
|
||||||
|
elif ctx.game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player
|
||||||
|
await dkc3_deathlink_kill_player(ctx)
|
||||||
ctx.last_death_link = time.time()
|
ctx.last_death_link = time.time()
|
||||||
|
|
||||||
|
|
||||||
SNES_RECONNECT_DELAY = 5
|
SNES_RECONNECT_DELAY = 5
|
||||||
|
|
||||||
# LttP
|
# FXPAK Pro protocol memory mapping used by SNI
|
||||||
ROM_START = 0x000000
|
ROM_START = 0x000000
|
||||||
WRAM_START = 0xF50000
|
WRAM_START = 0xF50000
|
||||||
WRAM_SIZE = 0x20000
|
WRAM_SIZE = 0x20000
|
||||||
@@ -287,21 +290,24 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5
|
|||||||
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
||||||
|
|
||||||
# SM
|
# SM
|
||||||
SM_ROMNAME_START = 0x007FC0
|
SM_ROMNAME_START = ROM_START + 0x007FC0
|
||||||
|
|
||||||
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||||
SM_ENDGAME_MODES = {0x26, 0x27}
|
SM_ENDGAME_MODES = {0x26, 0x27}
|
||||||
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
|
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
|
||||||
|
|
||||||
SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
|
# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue
|
||||||
SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
SM_RECV_QUEUE_START = SRAM_START + 0x2000
|
||||||
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602
|
||||||
|
SM_SEND_QUEUE_START = SRAM_START + 0x2700
|
||||||
|
SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680
|
||||||
|
SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682
|
||||||
|
|
||||||
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
||||||
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
|
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
|
||||||
|
|
||||||
# SMZ3
|
# SMZ3
|
||||||
SMZ3_ROMNAME_START = 0x00FFC0
|
SMZ3_ROMNAME_START = ROM_START + 0x00FFC0
|
||||||
|
|
||||||
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
|
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||||
SMZ3_ENDGAME_MODES = {0x26, 0x27}
|
SMZ3_ENDGAME_MODES = {0x26, 0x27}
|
||||||
@@ -595,7 +601,7 @@ class SNESState(enum.IntEnum):
|
|||||||
SNES_ATTACHED = 3
|
SNES_ATTACHED = 3
|
||||||
|
|
||||||
|
|
||||||
def launch_sni(ctx: Context):
|
def launch_sni():
|
||||||
sni_path = Utils.get_options()["lttp_options"]["sni"]
|
sni_path = Utils.get_options()["lttp_options"]["sni"]
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
@@ -633,11 +639,9 @@ async def _snes_connect(ctx: Context, address: str):
|
|||||||
address = f"ws://{address}" if "://" not in address else address
|
address = f"ws://{address}" if "://" not in address else address
|
||||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||||
seen_problems = set()
|
seen_problems = set()
|
||||||
succesful = False
|
while 1:
|
||||||
while not succesful:
|
|
||||||
try:
|
try:
|
||||||
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
|
snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None)
|
||||||
succesful = True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
problem = "%s" % e
|
problem = "%s" % e
|
||||||
# only tell the user about new problems, otherwise silently lay in wait for a working connection
|
# only tell the user about new problems, otherwise silently lay in wait for a working connection
|
||||||
@@ -647,7 +651,7 @@ async def _snes_connect(ctx: Context, address: str):
|
|||||||
|
|
||||||
if len(seen_problems) == 1:
|
if len(seen_problems) == 1:
|
||||||
# this is the first problem. Let's try launching SNI if it isn't already running
|
# this is the first problem. Let's try launching SNI if it isn't already running
|
||||||
launch_sni(ctx)
|
launch_sni()
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
else:
|
else:
|
||||||
@@ -1034,47 +1038,54 @@ async def game_watcher(ctx: Context):
|
|||||||
if not ctx.rom:
|
if not ctx.rom:
|
||||||
ctx.finished_game = False
|
ctx.finished_game = False
|
||||||
ctx.death_link_allow_survive = False
|
ctx.death_link_allow_survive = False
|
||||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
|
||||||
if game_name is None:
|
|
||||||
continue
|
|
||||||
elif game_name[:2] == b"SM":
|
|
||||||
ctx.game = GAME_SM
|
|
||||||
# versions lower than 0.3.0 dont have item handling flag nor remote item support
|
|
||||||
romVersion = int(game_name[2:5].decode('UTF-8'))
|
|
||||||
if romVersion < 30:
|
|
||||||
ctx.items_handling = 0b001 # full local
|
|
||||||
else:
|
|
||||||
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
|
||||||
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
|
||||||
else:
|
|
||||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
|
||||||
if game_name == b"ZSM":
|
|
||||||
ctx.game = GAME_SMZ3
|
|
||||||
ctx.items_handling = 0b101 # local items and remote start inventory
|
|
||||||
else:
|
|
||||||
ctx.game = GAME_ALTTP
|
|
||||||
ctx.items_handling = 0b001 # full local
|
|
||||||
|
|
||||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
|
from worlds.dkc3.Client import dkc3_rom_init
|
||||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
init_handled = await dkc3_rom_init(ctx)
|
||||||
continue
|
if not init_handled:
|
||||||
|
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||||
|
if game_name is None:
|
||||||
|
continue
|
||||||
|
elif game_name[:2] == b"SM":
|
||||||
|
ctx.game = GAME_SM
|
||||||
|
# versions lower than 0.3.0 dont have item handling flag nor remote item support
|
||||||
|
romVersion = int(game_name[2:5].decode('UTF-8'))
|
||||||
|
if romVersion < 30:
|
||||||
|
ctx.items_handling = 0b001 # full local
|
||||||
|
else:
|
||||||
|
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
||||||
|
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
||||||
|
else:
|
||||||
|
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||||
|
if game_name == b"ZSM":
|
||||||
|
ctx.game = GAME_SMZ3
|
||||||
|
ctx.items_handling = 0b101 # local items and remote start inventory
|
||||||
|
else:
|
||||||
|
ctx.game = GAME_ALTTP
|
||||||
|
ctx.items_handling = 0b001 # full local
|
||||||
|
|
||||||
ctx.rom = rom
|
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE)
|
||||||
if ctx.game != GAME_SMZ3:
|
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
continue
|
||||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
|
||||||
if death_link:
|
ctx.rom = rom
|
||||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
if ctx.game != GAME_SMZ3:
|
||||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
if death_link:
|
||||||
ctx.locations_checked = set()
|
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||||
ctx.locations_scouted = set()
|
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||||
ctx.locations_info = {}
|
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||||
ctx.prev_rom = ctx.rom
|
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||||
|
ctx.locations_checked = set()
|
||||||
|
ctx.locations_scouted = set()
|
||||||
|
ctx.locations_info = {}
|
||||||
|
ctx.prev_rom = ctx.rom
|
||||||
|
|
||||||
if ctx.awaiting_rom:
|
if ctx.awaiting_rom:
|
||||||
await ctx.server_auth(False)
|
await ctx.server_auth(False)
|
||||||
|
elif ctx.server is None:
|
||||||
|
snes_logger.warning("ROM detected but no active multiworld server connection. " +
|
||||||
|
"Connect using command: /connect server:port")
|
||||||
|
|
||||||
if ctx.auth and ctx.auth != ctx.rom:
|
if ctx.auth and ctx.auth != ctx.rom:
|
||||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||||
@@ -1151,6 +1162,9 @@ async def game_watcher(ctx: Context):
|
|||||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||||
await track_locations(ctx, roomid, roomdata)
|
await track_locations(ctx, roomid, roomdata)
|
||||||
elif ctx.game == GAME_SM:
|
elif ctx.game == GAME_SM:
|
||||||
|
if ctx.server is None or ctx.slot is None:
|
||||||
|
# not successfully connected to a multiworld server, cannot process the game sending items
|
||||||
|
continue
|
||||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||||
currently_dead = gamemode[0] in SM_DEATH_MODES
|
currently_dead = gamemode[0] in SM_DEATH_MODES
|
||||||
@@ -1161,25 +1175,25 @@ async def game_watcher(ctx: Context):
|
|||||||
ctx.finished_game = True
|
ctx.finished_game = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
|
data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4)
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
recv_index = data[0] | (data[1] << 8)
|
recv_index = data[0] | (data[1] << 8)
|
||||||
recv_item = data[2] | (data[3] << 8)
|
recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT
|
||||||
|
|
||||||
while (recv_index < recv_item):
|
while (recv_index < recv_item):
|
||||||
itemAdress = recv_index * 8
|
itemAdress = recv_index * 8
|
||||||
message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
|
message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8)
|
||||||
# worldId = message[0] | (message[1] << 8) # unused
|
# worldId = message[0] | (message[1] << 8) # unused
|
||||||
# itemId = message[2] | (message[3] << 8) # unused
|
# itemId = message[2] | (message[3] << 8) # unused
|
||||||
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
||||||
|
|
||||||
recv_index += 1
|
recv_index += 1
|
||||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
|
snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT,
|
||||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||||
|
|
||||||
from worlds.sm.Locations import locations_start_id
|
from worlds.sm import locations_start_id
|
||||||
location_id = locations_start_id + itemIndex
|
location_id = locations_start_id + itemIndex
|
||||||
|
|
||||||
ctx.locations_checked.add(location_id)
|
ctx.locations_checked.add(location_id)
|
||||||
@@ -1188,15 +1202,14 @@ async def game_watcher(ctx: Context):
|
|||||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||||
|
|
||||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
|
data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2)
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
|
itemOutPtr = data[0] | (data[1] << 8)
|
||||||
itemOutPtr = data[2] | (data[3] << 8)
|
|
||||||
|
|
||||||
from worlds.sm.Items import items_start_id
|
from worlds.sm import items_start_id
|
||||||
from worlds.sm.Locations import locations_start_id
|
from worlds.sm import locations_start_id
|
||||||
if itemOutPtr < len(ctx.items_received):
|
if itemOutPtr < len(ctx.items_received):
|
||||||
item = ctx.items_received[itemOutPtr]
|
item = ctx.items_received[itemOutPtr]
|
||||||
itemId = item.item - items_start_id
|
itemId = item.item - items_start_id
|
||||||
@@ -1206,10 +1219,10 @@ async def game_watcher(ctx: Context):
|
|||||||
locationId = 0x00 #backward compat
|
locationId = 0x00 #backward compat
|
||||||
|
|
||||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
|
snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes(
|
||||||
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
|
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
|
||||||
itemOutPtr += 1
|
itemOutPtr += 1
|
||||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
|
snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT,
|
||||||
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||||
color(ctx.item_names[item.item], 'red', 'bold'),
|
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||||
@@ -1217,6 +1230,9 @@ async def game_watcher(ctx: Context):
|
|||||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||||
await snes_flush_writes(ctx)
|
await snes_flush_writes(ctx)
|
||||||
elif ctx.game == GAME_SMZ3:
|
elif ctx.game == GAME_SMZ3:
|
||||||
|
if ctx.server is None or ctx.slot is None:
|
||||||
|
# not successfully connected to a multiworld server, cannot process the game sending items
|
||||||
|
continue
|
||||||
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
|
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
|
||||||
if (currentGame is not None):
|
if (currentGame is not None):
|
||||||
if (currentGame[0] != 0):
|
if (currentGame[0] != 0):
|
||||||
@@ -1252,7 +1268,8 @@ async def game_watcher(ctx: Context):
|
|||||||
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||||
|
|
||||||
from worlds.smz3.TotalSMZ3.Location import locations_start_id
|
from worlds.smz3.TotalSMZ3.Location import locations_start_id
|
||||||
location_id = locations_start_id + itemIndex
|
from worlds.smz3 import convertLocSMZ3IDToAPID
|
||||||
|
location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex)
|
||||||
|
|
||||||
ctx.locations_checked.add(location_id)
|
ctx.locations_checked.add(location_id)
|
||||||
location = ctx.location_names[location_id]
|
location = ctx.location_names[location_id]
|
||||||
@@ -1279,6 +1296,9 @@ async def game_watcher(ctx: Context):
|
|||||||
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||||
await snes_flush_writes(ctx)
|
await snes_flush_writes(ctx)
|
||||||
|
elif ctx.game == GAME_DKC3:
|
||||||
|
from worlds.dkc3.Client import dkc3_game_watcher
|
||||||
|
await dkc3_game_watcher(ctx)
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
@@ -1296,7 +1316,7 @@ async def main():
|
|||||||
parser = get_base_parser()
|
parser = get_base_parser()
|
||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||||
help='Path to a Archipelago Binary Patch file')
|
help='Path to a Archipelago Binary Patch file')
|
||||||
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
|
parser.add_argument('--snes', default='localhost:23074', help='Address of the SNI server.')
|
||||||
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
195
Utils.py
195
Utils.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
import os
|
import os
|
||||||
@@ -12,12 +11,18 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import decimal
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yaml import CLoader as UnsafeLoader
|
||||||
|
from yaml import CDumper as Dumper
|
||||||
|
except ImportError:
|
||||||
|
from yaml import Loader as UnsafeLoader
|
||||||
|
from yaml import Dumper
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from tkinter import Tk
|
import tkinter
|
||||||
else:
|
import pathlib
|
||||||
Tk = typing.Any
|
|
||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
@@ -30,21 +35,13 @@ class Version(typing.NamedTuple):
|
|||||||
build: int
|
build: int
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.3.3"
|
__version__ = "0.3.5"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith('linux')
|
is_linux = sys.platform.startswith("linux")
|
||||||
is_macos = sys.platform == 'darwin'
|
is_macos = sys.platform == "darwin"
|
||||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||||
|
|
||||||
import jellyfish
|
|
||||||
from yaml import load, load_all, dump, SafeLoader
|
|
||||||
|
|
||||||
try:
|
|
||||||
from yaml import CLoader as Loader
|
|
||||||
except ImportError:
|
|
||||||
from yaml import Loader
|
|
||||||
|
|
||||||
|
|
||||||
def int16_as_bytes(value: int) -> typing.List[int]:
|
def int16_as_bytes(value: int) -> typing.List[int]:
|
||||||
value = value & 0xFFFF
|
value = value & 0xFFFF
|
||||||
@@ -125,17 +122,18 @@ def home_path(*path: str) -> str:
|
|||||||
|
|
||||||
def user_path(*path: str) -> str:
|
def user_path(*path: str) -> str:
|
||||||
"""Returns either local_path or home_path based on write permissions."""
|
"""Returns either local_path or home_path based on write permissions."""
|
||||||
if hasattr(user_path, 'cached_path'):
|
if hasattr(user_path, "cached_path"):
|
||||||
pass
|
pass
|
||||||
elif os.access(local_path(), os.W_OK):
|
elif os.access(local_path(), os.W_OK):
|
||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
# populate home from local - TODO: upgrade feature
|
# populate home from local - TODO: upgrade feature
|
||||||
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
|
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
|
||||||
for dn in ('Players', 'data/sprites'):
|
import shutil
|
||||||
|
for dn in ("Players", "data/sprites"):
|
||||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||||
for fn in ('manifest.json', 'host.yaml'):
|
for fn in ("manifest.json", "host.yaml"):
|
||||||
shutil.copy2(local_path(fn), user_path(fn))
|
shutil.copy2(local_path(fn), user_path(fn))
|
||||||
|
|
||||||
return os.path.join(user_path.cached_path, *path)
|
return os.path.join(user_path.cached_path, *path)
|
||||||
@@ -150,11 +148,12 @@ def output_path(*path: str):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def open_file(filename):
|
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
||||||
if sys.platform == 'win32':
|
if is_windows:
|
||||||
os.startfile(filename)
|
os.startfile(filename)
|
||||||
else:
|
else:
|
||||||
open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
|
from shutil import which
|
||||||
|
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||||
subprocess.call([open_command, filename])
|
subprocess.call([open_command, filename])
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +172,9 @@ class UniqueKeyLoader(SafeLoader):
|
|||||||
|
|
||||||
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
||||||
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
||||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader)
|
||||||
|
|
||||||
|
del load, load_all # should not be used. don't leak their names
|
||||||
|
|
||||||
|
|
||||||
def get_cert_none_ssl_context():
|
def get_cert_none_ssl_context():
|
||||||
@@ -191,11 +192,12 @@ def get_public_ipv4() -> str:
|
|||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
|
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# noinspection PyBroadException
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
|
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
|
||||||
except:
|
except Exception:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, so no point in erroring out
|
pass # we could be offline, in a local game, so no point in erroring out
|
||||||
return ip
|
return ip
|
||||||
@@ -208,7 +210,7 @@ def get_public_ipv6() -> str:
|
|||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
|
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||||
@@ -277,7 +279,12 @@ def get_default_options() -> dict:
|
|||||||
},
|
},
|
||||||
"oot_options": {
|
"oot_options": {
|
||||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||||
}
|
},
|
||||||
|
"dkc3_options": {
|
||||||
|
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||||
|
"sni": "SNI",
|
||||||
|
"rom_start": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
@@ -304,33 +311,19 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
|||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_options() -> dict:
|
def get_options() -> dict:
|
||||||
if not hasattr(get_options, "options"):
|
filenames = ("options.yaml", "host.yaml")
|
||||||
filenames = ("options.yaml", "host.yaml")
|
locations = []
|
||||||
locations = []
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
if os.path.join(os.getcwd()) != local_path():
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
locations += filenames # use files from cwd only if it's not the local_path
|
locations += [user_path(filename) for filename in filenames]
|
||||||
locations += [user_path(filename) for filename in filenames]
|
|
||||||
|
|
||||||
for location in locations:
|
for location in locations:
|
||||||
if os.path.exists(location):
|
if os.path.exists(location):
|
||||||
with open(location) as f:
|
with open(location) as f:
|
||||||
options = parse_yaml(f.read())
|
options = parse_yaml(f.read())
|
||||||
|
return update_options(get_default_options(), options, location, list())
|
||||||
|
|
||||||
get_options.options = update_options(get_default_options(), options, location, list())
|
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
|
||||||
return get_options.options
|
|
||||||
|
|
||||||
|
|
||||||
def get_item_name_from_id(code: int) -> str:
|
|
||||||
from worlds import lookup_any_item_id_to_name
|
|
||||||
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
|
|
||||||
|
|
||||||
|
|
||||||
def get_location_name_from_id(code: int) -> str:
|
|
||||||
from worlds import lookup_any_location_id_to_name
|
|
||||||
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
|
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||||
@@ -339,10 +332,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
|||||||
category = storage.setdefault(category, {})
|
category = storage.setdefault(category, {})
|
||||||
category[key] = value
|
category[key] = value
|
||||||
with open(path, "wt") as f:
|
with open(path, "wt") as f:
|
||||||
f.write(dump(storage))
|
f.write(dump(storage, Dumper=Dumper))
|
||||||
|
|
||||||
|
|
||||||
def persistent_load() -> typing.Dict[dict]:
|
def persistent_load() -> typing.Dict[str, dict]:
|
||||||
storage = getattr(persistent_load, "storage", None)
|
storage = getattr(persistent_load, "storage", None)
|
||||||
if storage:
|
if storage:
|
||||||
return storage
|
return storage
|
||||||
@@ -360,8 +353,8 @@ def persistent_load() -> typing.Dict[dict]:
|
|||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def get_adjuster_settings(gameName: str):
|
def get_adjuster_settings(game_name: str):
|
||||||
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
|
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||||
return adjuster_settings
|
return adjuster_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -377,10 +370,10 @@ def get_unique_identifier():
|
|||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
safe_builtins = {
|
safe_builtins = frozenset((
|
||||||
'set',
|
'set',
|
||||||
'frozenset',
|
'frozenset',
|
||||||
}
|
))
|
||||||
|
|
||||||
|
|
||||||
class RestrictedUnpickler(pickle.Unpickler):
|
class RestrictedUnpickler(pickle.Unpickler):
|
||||||
@@ -408,8 +401,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
if issubclass(obj, self.options_module.Option):
|
if issubclass(obj, self.options_module.Option):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
(module, name))
|
|
||||||
|
|
||||||
|
|
||||||
def restricted_loads(s):
|
def restricted_loads(s):
|
||||||
@@ -418,6 +410,9 @@ def restricted_loads(s):
|
|||||||
|
|
||||||
|
|
||||||
class KeyedDefaultDict(collections.defaultdict):
|
class KeyedDefaultDict(collections.defaultdict):
|
||||||
|
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||||
|
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||||
|
|
||||||
def __missing__(self, key):
|
def __missing__(self, key):
|
||||||
self[key] = value = self.default_factory(key)
|
self[key] = value = self.default_factory(key)
|
||||||
return value
|
return value
|
||||||
@@ -427,6 +422,10 @@ def get_text_between(text: str, start: str, end: str) -> str:
|
|||||||
return text[text.index(start) + len(start): text.rindex(end)]
|
return text[text.index(start) + len(start): text.rindex(end)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_after(text: str, start: str) -> str:
|
||||||
|
return text[text.index(start) + len(start):]
|
||||||
|
|
||||||
|
|
||||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||||
|
|
||||||
|
|
||||||
@@ -474,9 +473,13 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
def stream_input(stream, queue):
|
def stream_input(stream, queue):
|
||||||
def queuer():
|
def queuer():
|
||||||
while 1:
|
while 1:
|
||||||
text = stream.readline().strip()
|
try:
|
||||||
if text:
|
text = stream.readline().strip()
|
||||||
queue.put_nowait(text)
|
except UnicodeDecodeError as e:
|
||||||
|
logging.exception(e)
|
||||||
|
else:
|
||||||
|
if text:
|
||||||
|
queue.put_nowait(text)
|
||||||
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||||
@@ -484,11 +487,11 @@ def stream_input(stream, queue):
|
|||||||
return thread
|
return thread
|
||||||
|
|
||||||
|
|
||||||
def tkinter_center_window(window: Tk):
|
def tkinter_center_window(window: "tkinter.Tk") -> None:
|
||||||
window.update()
|
window.update()
|
||||||
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||||
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||||
window.geometry("+{}+{}".format(xPos, yPos))
|
window.geometry(f"+{x}+{y}")
|
||||||
|
|
||||||
|
|
||||||
class VersionException(Exception):
|
class VersionException(Exception):
|
||||||
@@ -505,24 +508,27 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
# noinspection PyPep8Naming
|
||||||
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
|
def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str:
|
||||||
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
|
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
|
||||||
|
import decimal
|
||||||
n = 0
|
n = 0
|
||||||
value = decimal.Decimal(value)
|
value = decimal.Decimal(value)
|
||||||
while value >= power:
|
limit = power - decimal.Decimal("0.005")
|
||||||
|
while value >= limit:
|
||||||
value /= power
|
value /= power
|
||||||
n += 1
|
n += 1
|
||||||
|
|
||||||
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
|
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
|
||||||
|
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
|
||||||
/ max(len(word1), len(word2)))
|
|
||||||
|
|
||||||
|
|
||||||
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
||||||
-> typing.List[typing.Tuple[str, int]]:
|
-> typing.List[typing.Tuple[str, int]]:
|
||||||
|
import jellyfish
|
||||||
|
|
||||||
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
limit: int = limit if limit else len(wordlist)
|
limit: int = limit if limit else len(wordlist)
|
||||||
return list(
|
return list(
|
||||||
map(
|
map(
|
||||||
@@ -540,18 +546,19 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
|
|||||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
def run(*args: str):
|
def run(*args: str):
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
kdialog = shutil.which('kdialog')
|
from shutil import which
|
||||||
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
|
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
|
||||||
zenity = shutil.which('zenity')
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
|
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -569,10 +576,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
|||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||||
def run(*args: str):
|
def run(*args: str):
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
def is_kivy_running():
|
def is_kivy_running():
|
||||||
if 'kivy' in sys.modules:
|
if "kivy" in sys.modules:
|
||||||
from kivy.app import App
|
from kivy.app import App
|
||||||
return App.get_running_app() is not None
|
return App.get_running_app() is not None
|
||||||
return False
|
return False
|
||||||
@@ -582,14 +589,15 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
MessageBox(title, text, error).open()
|
MessageBox(title, text, error).open()
|
||||||
return
|
return
|
||||||
|
|
||||||
if is_linux and not 'tkinter' in sys.modules:
|
if is_linux and "tkinter" not in sys.modules:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
kdialog = shutil.which('kdialog')
|
from shutil import which
|
||||||
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
|
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||||
zenity = shutil.which('zenity')
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
|
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -604,3 +612,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
root.withdraw()
|
root.withdraw()
|
||||||
showerror(title, text) if error else showinfo(title, text)
|
showerror(title, text) if error else showinfo(title, text)
|
||||||
root.update()
|
root.update()
|
||||||
|
|
||||||
|
|
||||||
|
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
||||||
|
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||||
|
def sorter(element: str) -> str:
|
||||||
|
parts = element.split(maxsplit=1)
|
||||||
|
if parts[0].lower() in ignore:
|
||||||
|
return parts[1].lower()
|
||||||
|
else:
|
||||||
|
return element.lower()
|
||||||
|
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||||
|
|||||||
41
WebHost.py
41
WebHost.py
@@ -12,9 +12,9 @@ ModuleUpdate.update()
|
|||||||
# in case app gets imported by something like gunicorn
|
# in case app gets imported by something like gunicorn
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||||
|
|
||||||
from WebHostLib import app as raw_app
|
from WebHostLib import register, app as raw_app
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from WebHostLib.models import db
|
from WebHostLib.models import db
|
||||||
@@ -22,14 +22,13 @@ from WebHostLib.autolauncher import autohost, autogen
|
|||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
|
|
||||||
configpath = os.path.abspath("config.yaml")
|
configpath = os.path.abspath("config.yaml")
|
||||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||||
|
|
||||||
|
|
||||||
def get_app():
|
def get_app():
|
||||||
|
register()
|
||||||
app = raw_app
|
app = raw_app
|
||||||
if os.path.exists(configpath):
|
if os.path.exists(configpath):
|
||||||
import yaml
|
import yaml
|
||||||
@@ -43,19 +42,39 @@ def get_app():
|
|||||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
zfile: zipfile.ZipInfo
|
||||||
|
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
worlds = {}
|
worlds = {}
|
||||||
data = []
|
data = []
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
|
|
||||||
|
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||||
for game, world in worlds.items():
|
for game, world in worlds.items():
|
||||||
# copy files from world's docs folder to the generated folder
|
# copy files from world's docs folder to the generated folder
|
||||||
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
|
target_path = os.path.join(base_target_path, game)
|
||||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
files = os.listdir(source_path)
|
|
||||||
for file in files:
|
if world.zip_path:
|
||||||
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
|
zipfile_path = world.zip_path
|
||||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
|
||||||
|
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
|
||||||
|
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zipfile_path) as zf:
|
||||||
|
for zfile in zf.infolist():
|
||||||
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
|
zf.extract(zfile, target_path)
|
||||||
|
else:
|
||||||
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
|
files = os.listdir(source_path)
|
||||||
|
for file in files:
|
||||||
|
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||||
|
|
||||||
# build a json tutorial dict per game
|
# build a json tutorial dict per game
|
||||||
game_data = {'gameTitle': game, 'tutorials': []}
|
game_data = {'gameTitle': game, 'tutorials': []}
|
||||||
for tutorial in world.web.tutorials:
|
for tutorial in world.web.tutorials:
|
||||||
@@ -85,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for games in data:
|
for games in data:
|
||||||
if 'Archipelago' in games['gameTitle']:
|
if 'Archipelago' in games['gameTitle']:
|
||||||
generic_data = data.pop(data.index(games))
|
generic_data = data.pop(data.index(games))
|
||||||
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
|
|||||||
46
WebHostLib/README.md
Normal file
46
WebHostLib/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# WebHost
|
||||||
|
|
||||||
|
## Contribution Guidelines
|
||||||
|
**Thank you for your interest in contributing to the Archipelago website!**
|
||||||
|
Much of the content on the website is generated automatically, but there are some things
|
||||||
|
that need a personal touch. For those things, we rely on contributions from both the core
|
||||||
|
team and the community. The current primary maintainer of the website is Farrak Kilhn.
|
||||||
|
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
|
||||||
|
|
||||||
|
### Small Changes
|
||||||
|
Little changes like adding a button or a couple new select elements are perfectly fine.
|
||||||
|
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
|
||||||
|
you build a new page which needs two side by side tables, and you need to write a CSS file
|
||||||
|
specific to your page, that is perfectly reasonable.
|
||||||
|
|
||||||
|
### Content Additions
|
||||||
|
Once you develop a new feature or add new content the website, make a pull request. It will
|
||||||
|
be reviewed by the community and there will probably be some discussion around it. Depending
|
||||||
|
on the size of the feature, and if new styles are required, there may be an additional step
|
||||||
|
before the PR is accepted wherein Farrak works with the designer to implement styles.
|
||||||
|
|
||||||
|
### Restrictions on Style Changes
|
||||||
|
A professional designer is paid to develop the styles and assets for the Archipelago website.
|
||||||
|
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
|
||||||
|
change site styles are rejected. Please note this applies to code which changes the overall
|
||||||
|
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
|
||||||
|
behind these restrictions is to maintain a curated feel for the design of the site. If
|
||||||
|
any PR affects the overall feel of the site but includes additive changes, there will
|
||||||
|
likely be a conversation about how to implement those changes without compromising the
|
||||||
|
curated site style. It is therefore worth noting there are a couple files which, if
|
||||||
|
changed in your pull request, will cause it to draw additional scrutiny.
|
||||||
|
|
||||||
|
These closely guarded files are:
|
||||||
|
- `globalStyles.css`
|
||||||
|
- `islandFooter.css`
|
||||||
|
- `landing.css`
|
||||||
|
- `markdown.css`
|
||||||
|
- `tooltip.css`
|
||||||
|
|
||||||
|
### Site Themes
|
||||||
|
There are several themes available for game pages. It is possible to request a new theme in
|
||||||
|
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
|
||||||
|
are not free, and take some time to create. Farrak works closely with the designer to implement
|
||||||
|
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
|
||||||
|
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
|
||||||
|
good chance it will become a reality.
|
||||||
@@ -3,13 +3,13 @@ import uuid
|
|||||||
import base64
|
import base64
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
import jinja2.exceptions
|
|
||||||
from pony.flask import Pony
|
from pony.flask import Pony
|
||||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from Utils import title_sorted
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
@@ -53,8 +53,6 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
|
|||||||
cache = Cache(app)
|
cache = Cache(app)
|
||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
from werkzeug.routing import BaseConverter
|
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
@@ -68,165 +66,18 @@ class B64UUIDConverter(BaseConverter):
|
|||||||
# short UUID
|
# short UUID
|
||||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def register():
|
||||||
if game_name in AutoWorldRegister.world_types:
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
Note: initializes worlds subsystem."""
|
||||||
return 'grass'
|
# has automatic patch integration
|
||||||
|
import Patch
|
||||||
|
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
||||||
|
|
||||||
|
from WebHostLib.customserver import run_server_process
|
||||||
|
# to trigger app routing picking up on it
|
||||||
|
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||||
|
|
||||||
@app.before_request
|
app.register_blueprint(api.api_endpoints)
|
||||||
def register_session():
|
|
||||||
session.permanent = True # technically 31 days after the last visit
|
|
||||||
if not session.get("_id", None):
|
|
||||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
|
||||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
|
||||||
def page_not_found(err):
|
|
||||||
return render_template('404.html'), 404
|
|
||||||
|
|
||||||
|
|
||||||
# Start Playing Page
|
|
||||||
@app.route('/start-playing')
|
|
||||||
def start_playing():
|
|
||||||
return render_template(f"startPlaying.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/weighted-settings')
|
|
||||||
def weighted_settings():
|
|
||||||
return render_template(f"weighted-settings.html")
|
|
||||||
|
|
||||||
|
|
||||||
# Player settings pages
|
|
||||||
@app.route('/games/<string:game>/player-settings')
|
|
||||||
def player_settings(game):
|
|
||||||
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
|
||||||
|
|
||||||
|
|
||||||
# Game Info Pages
|
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
|
||||||
def game_info(game, lang):
|
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
|
||||||
|
|
||||||
|
|
||||||
# List of supported games
|
|
||||||
@app.route('/games')
|
|
||||||
def games():
|
|
||||||
worlds = {}
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("supportedGames.html", worlds=worlds)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
|
||||||
def tutorial(game, file, lang):
|
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
|
||||||
def tutorial_landing():
|
|
||||||
worlds = {}
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("tutorialLanding.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
|
||||||
def faq(lang):
|
|
||||||
return render_template("faq.html", lang=lang)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/seed/<suuid:seed>')
|
|
||||||
def view_seed(seed: UUID):
|
|
||||||
seed = Seed.get(id=seed)
|
|
||||||
if not seed:
|
|
||||||
abort(404)
|
|
||||||
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/new_room/<suuid:seed>')
|
|
||||||
def new_room(seed: UUID):
|
|
||||||
seed = Seed.get(id=seed)
|
|
||||||
if not seed:
|
|
||||||
abort(404)
|
|
||||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
|
||||||
commit()
|
|
||||||
return redirect(url_for("host_room", room=room.id))
|
|
||||||
|
|
||||||
|
|
||||||
def _read_log(path: str):
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, encoding="utf-8-sig") as log:
|
|
||||||
yield from log
|
|
||||||
else:
|
|
||||||
yield f"Logfile {path} does not exist. " \
|
|
||||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/log/<suuid:room>')
|
|
||||||
def display_log(room: UUID):
|
|
||||||
room = Room.get(id=room)
|
|
||||||
if room is None:
|
|
||||||
return abort(404)
|
|
||||||
if room.owner == session["_id"]:
|
|
||||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
|
||||||
return "Access Denied", 403
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
|
||||||
def host_room(room: UUID):
|
|
||||||
room = Room.get(id=room)
|
|
||||||
if room is None:
|
|
||||||
return abort(404)
|
|
||||||
if request.method == "POST":
|
|
||||||
if room.owner == session["_id"]:
|
|
||||||
cmd = request.form["cmd"]
|
|
||||||
if cmd:
|
|
||||||
Command(room=room, commandtext=cmd)
|
|
||||||
commit()
|
|
||||||
|
|
||||||
with db_session:
|
|
||||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
|
||||||
def favicon():
|
|
||||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
|
||||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/discord')
|
|
||||||
def discord():
|
|
||||||
return redirect("https://discord.gg/archipelago")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/datapackage')
|
|
||||||
@cache.cached()
|
|
||||||
def get_datapackge():
|
|
||||||
"""A pretty print version of /api/datapackage"""
|
|
||||||
from worlds import network_data_package
|
|
||||||
import json
|
|
||||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/index')
|
|
||||||
@app.route('/sitemap')
|
|
||||||
def get_sitemap():
|
|
||||||
available_games = []
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
|
||||||
if not world.hidden:
|
|
||||||
available_games.append(game)
|
|
||||||
return render_template("siteMap.html", games=available_games)
|
|
||||||
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
|
|
||||||
|
|
||||||
app.register_blueprint(api.api_endpoints)
|
|
||||||
|
|||||||
@@ -32,18 +32,18 @@ def room_info(room: UUID):
|
|||||||
|
|
||||||
@api_endpoints.route('/datapackage')
|
@api_endpoints.route('/datapackage')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def get_datapackge():
|
def get_datapackage():
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
return network_data_package
|
return network_data_package
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route('/datapackage_version')
|
@api_endpoints.route('/datapackage_version')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def get_datapackge_versions():
|
def get_datapackage_versions():
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||||
version_package["version"] = network_data_package["version"]
|
version_package["version"] = network_data_package["version"]
|
||||||
return version_package
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
from . import generate, user # trigger registration
|
from . import generate, user, tracker # trigger registration
|
||||||
|
|||||||
50
WebHostLib/api/tracker.py
Normal file
50
WebHostLib/api/tracker.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import collections
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
|
from Utils import restricted_loads
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from ..models import Room
|
||||||
|
from . import api_endpoints
|
||||||
|
from ..tracker import fill_tracker_data, get_static_room_data
|
||||||
|
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||||
|
from WebHostLib import cache
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def update_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
||||||
|
|
||||||
|
room: Optional[Room] = Room.get(tracker=tracker)
|
||||||
|
locations = get_static_room_data(room)[0]
|
||||||
|
items_counter: Dict[int, collections.Counter] = get_item_names_counter(locations)
|
||||||
|
player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, \
|
||||||
|
slot_data, games, player_name, display_icons = fill_tracker_data(room, tracked_team, tracked_player)
|
||||||
|
|
||||||
|
# convert numbers to string
|
||||||
|
for item in player_tracker.items_received:
|
||||||
|
if items_counter[tracked_player][item] == 1:
|
||||||
|
player_tracker.items_received[item] = '✔'
|
||||||
|
else:
|
||||||
|
player_tracker.items_received[item] = str(player_tracker.items_received[item])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"items_received": player_tracker.items_received,
|
||||||
|
"checked_locations": list(sorted(player_tracker.checked_locations)),
|
||||||
|
"icons": display_icons,
|
||||||
|
"progressive_names": player_tracker.progressive_names
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@cache.cached()
|
||||||
|
def get_item_names_counter(locations: Dict[int, Dict[int, Tuple[int, int, int]]]):
|
||||||
|
# create and fill dictionary of all progression items for players
|
||||||
|
items_counters: Dict[int, collections.Counter] = {}
|
||||||
|
for player in locations:
|
||||||
|
for location in locations[player]:
|
||||||
|
item, recipient, flags = locations[player][location]
|
||||||
|
item_name = lookup_any_item_id_to_name[item]
|
||||||
|
items_counters.setdefault(recipient, collections.Counter())[item_name] += 1
|
||||||
|
|
||||||
|
return items_counters
|
||||||
@@ -154,8 +154,10 @@ def autogen(config: dict):
|
|||||||
while 1:
|
while 1:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
with db_session:
|
with db_session:
|
||||||
|
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||||
to_start = select(
|
to_start = select(
|
||||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
generation for generation in Generation
|
||||||
|
if generation.state == STATE_QUEUED).for_update()
|
||||||
for generation in to_start:
|
for generation in to_start:
|
||||||
launch_generator(generator_pool, generation)
|
launch_generator(generator_pool, generation)
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
@@ -182,7 +184,7 @@ class MultiworldInstance():
|
|||||||
|
|
||||||
logging.info(f"Spinning up {self.room_id}")
|
logging.info(f"Spinning up {self.room_id}")
|
||||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||||
args=(self.room_id, self.ponyconfig),
|
args=(self.room_id, self.ponyconfig, get_static_server_data()),
|
||||||
name="MultiHost")
|
name="MultiHost")
|
||||||
process.start()
|
process.start()
|
||||||
# bind after start to prevent thread sync issues with guardian.
|
# bind after start to prevent thread sync issues with guardian.
|
||||||
@@ -236,5 +238,5 @@ def run_guardian():
|
|||||||
|
|
||||||
|
|
||||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||||
from .customserver import run_server_process
|
from .customserver import run_server_process, get_static_server_data
|
||||||
from .generate import gen_game
|
from .generate import gen_game
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
|||||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||||
|
|
||||||
|
|
||||||
from Generate import roll_settings
|
from Generate import roll_settings, PlandoSettings
|
||||||
from Utils import parse_yamls
|
from Utils import parse_yamls
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
|||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: Dict[str, Union[dict, str]],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||||
plando_options = set(plando_options)
|
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||||
results = {}
|
results = {}
|
||||||
rolled_results = {}
|
rolled_results = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
|
||||||
import websockets
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import random
|
import websockets
|
||||||
import pickle
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from .models import *
|
from .models import db_session, Room, select, commit, Command, db
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
|
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
@@ -39,7 +41,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
|||||||
import MultiServer
|
import MultiServer
|
||||||
|
|
||||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||||
del (MultiServer)
|
del MultiServer
|
||||||
|
|
||||||
|
|
||||||
class DBCommandProcessor(ServerCommandProcessor):
|
class DBCommandProcessor(ServerCommandProcessor):
|
||||||
@@ -48,12 +50,24 @@ class DBCommandProcessor(ServerCommandProcessor):
|
|||||||
|
|
||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
def __init__(self):
|
room_id: int
|
||||||
|
|
||||||
|
def __init__(self, static_server_data: dict):
|
||||||
|
# static server data is used during _load_game_data to load required data,
|
||||||
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
|
self.static_server_data = static_server_data
|
||||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||||
|
del self.static_server_data
|
||||||
self.main_loop = asyncio.get_running_loop()
|
self.main_loop = asyncio.get_running_loop()
|
||||||
self.video = {}
|
self.video = {}
|
||||||
self.tags = ["AP", "WebHost"]
|
self.tags = ["AP", "WebHost"]
|
||||||
|
|
||||||
|
def _load_game_data(self):
|
||||||
|
for key, value in self.static_server_data.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
|
||||||
|
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||||
|
|
||||||
def listen_to_db_commands(self):
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
|
|
||||||
@@ -94,7 +108,7 @@ class WebHostContext(Context):
|
|||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
room.last_activity = datetime.utcnow()
|
room.last_activity = datetime.datetime.utcnow()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
@@ -107,14 +121,32 @@ def get_random_port():
|
|||||||
return random.randint(49152, 65535)
|
return random.randint(49152, 65535)
|
||||||
|
|
||||||
|
|
||||||
def run_server_process(room_id, ponyconfig: dict):
|
@cache_argsless
|
||||||
|
def get_static_server_data() -> dict:
|
||||||
|
import worlds
|
||||||
|
data = {
|
||||||
|
"forced_auto_forfeits": {},
|
||||||
|
"non_hintable_names": {},
|
||||||
|
"gamespackage": worlds.network_data_package["games"],
|
||||||
|
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||||
|
worlds.AutoWorldRegister.world_types.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
|
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
||||||
|
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||||
# establish DB connection for multidata and multisave
|
# establish DB connection for multidata and multisave
|
||||||
db.bind(**ponyconfig)
|
db.bind(**ponyconfig)
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
Utils.init_logging(str(room_id), write_mode="a")
|
Utils.init_logging(str(room_id), write_mode="a")
|
||||||
ctx = WebHostContext()
|
ctx = WebHostContext(static_server_data)
|
||||||
ctx.load(room_id)
|
ctx.load(room_id)
|
||||||
ctx.init_save()
|
ctx.init_save()
|
||||||
|
|
||||||
|
|||||||
@@ -32,18 +32,21 @@ def download_patch(room_id, patch_id):
|
|||||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||||
else:
|
else:
|
||||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||||
|
if "patch_file_ending" in manifest:
|
||||||
|
patch_file_ending = manifest["patch_file_ending"]
|
||||||
|
else:
|
||||||
|
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
|
||||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||||
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
f"{patch_file_ending}"
|
||||||
new_file.seek(0)
|
new_file.seek(0)
|
||||||
return send_file(new_file, as_attachment=True, attachment_filename=fname)
|
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||||
else:
|
else:
|
||||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||||
patch_data = BytesIO(patch_data)
|
patch_data = BytesIO(patch_data)
|
||||||
|
|
||||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||||
f"{preferred_endings[patch.game]}"
|
f"{preferred_endings[patch.game]}"
|
||||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
return send_file(patch_data, as_attachment=True, download_name=fname)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||||
@@ -66,7 +69,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
from worlds.minecraft import mc_update_output
|
from worlds.minecraft import mc_update_output
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||||
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
|
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||||
elif slot_data.game == "Factorio":
|
elif slot_data.game == "Factorio":
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
@@ -78,9 +81,11 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||||
elif slot_data.game == "Super Mario 64":
|
elif slot_data.game == "Super Mario 64":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||||
|
elif slot_data.game == "Dark Souls III":
|
||||||
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||||
else:
|
else:
|
||||||
return "Game download not supported."
|
return "Game download not supported."
|
||||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/templates")
|
@app.route("/templates")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import random
|
|||||||
import json
|
import json
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Optional as TypeOptional
|
from typing import Dict, Optional, Any
|
||||||
from Utils import __version__
|
from Utils import __version__
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
from flask import request, flash, redirect, url_for, session, render_template
|
||||||
@@ -12,10 +12,10 @@ from flask import request, flash, redirect, url_for, session, render_template
|
|||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
from Generate import handle_name
|
from Generate import handle_name, PlandoSettings
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from .models import *
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
@@ -30,16 +30,15 @@ def get_meta(options_source: dict) -> dict:
|
|||||||
}
|
}
|
||||||
plando_options -= {""}
|
plando_options -= {""}
|
||||||
|
|
||||||
meta = {
|
server_options = {
|
||||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||||
"server_password": options_source.get("server_password", None),
|
"server_password": options_source.get("server_password", None),
|
||||||
"plando_options": list(plando_options)
|
|
||||||
}
|
}
|
||||||
return meta
|
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/generate', methods=['GET', 'POST'])
|
@app.route('/generate', methods=['GET', 'POST'])
|
||||||
@@ -60,13 +59,13 @@ def generate(race=False):
|
|||||||
results, gen_options = roll_options(options, meta["plando_options"])
|
results, gen_options = roll_options(options, meta["plando_options"])
|
||||||
|
|
||||||
if race:
|
if race:
|
||||||
meta["item_cheat"] = False
|
meta["server_options"]["item_cheat"] = False
|
||||||
meta["remaining_mode"] = "disabled"
|
meta["server_options"]["remaining_mode"] = "disabled"
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
return render_template("checkResult.html", results=results)
|
return render_template("checkResult.html", results=results)
|
||||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
|
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
@@ -92,35 +91,35 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||||
if not meta:
|
if not meta:
|
||||||
meta: Dict[str, object] = {}
|
meta: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
|
race = meta.setdefault("race", False)
|
||||||
|
|
||||||
meta.setdefault("hint_cost", 10)
|
|
||||||
race = meta.get("race", False)
|
|
||||||
del (meta["race"])
|
|
||||||
plando_options = meta.get("plando", {"bosses", "items", "connections", "texts"})
|
|
||||||
del (meta["plando_options"])
|
|
||||||
try:
|
try:
|
||||||
target = tempfile.TemporaryDirectory()
|
target = tempfile.TemporaryDirectory()
|
||||||
playercount = len(gen_options)
|
playercount = len(gen_options)
|
||||||
seed = get_seed()
|
seed = get_seed()
|
||||||
random.seed(seed)
|
|
||||||
|
|
||||||
if race:
|
if race:
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # use time-based random source
|
||||||
|
else:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
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)])
|
erargs = parse_arguments(['--multi', str(playercount)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
|
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.spoiler = 0 if race else 2
|
erargs.spoiler = 0 if race else 2
|
||||||
erargs.race = race
|
erargs.race = race
|
||||||
erargs.outputname = seedname
|
erargs.outputname = seedname
|
||||||
erargs.outputpath = target.name
|
erargs.outputpath = target.name
|
||||||
erargs.teams = 1
|
erargs.teams = 1
|
||||||
erargs.plando_options = ", ".join(plando_options)
|
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||||
|
{"bosses", "items", "connections", "texts"}))
|
||||||
|
|
||||||
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):
|
||||||
@@ -136,7 +135,7 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
|||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||||
ERmain(erargs, seed, baked_server_options=meta)
|
ERmain(erargs, 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)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
@@ -148,7 +147,6 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
|||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
173
WebHostLib/misc.py
Normal file
173
WebHostLib/misc.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
import jinja2.exceptions
|
||||||
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
|
|
||||||
|
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from . import app, cache
|
||||||
|
|
||||||
|
|
||||||
|
def get_world_theme(game_name: str):
|
||||||
|
if game_name in AutoWorldRegister.world_types:
|
||||||
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
|
return 'grass'
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def register_session():
|
||||||
|
session.permanent = True # technically 31 days after the last visit
|
||||||
|
if not session.get("_id", None):
|
||||||
|
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||||
|
def page_not_found(err):
|
||||||
|
return render_template('404.html'), 404
|
||||||
|
|
||||||
|
|
||||||
|
# Start Playing Page
|
||||||
|
@app.route('/start-playing')
|
||||||
|
def start_playing():
|
||||||
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/weighted-settings')
|
||||||
|
def weighted_settings():
|
||||||
|
return render_template(f"weighted-settings.html")
|
||||||
|
|
||||||
|
|
||||||
|
# Player settings pages
|
||||||
|
@app.route('/games/<string:game>/player-settings')
|
||||||
|
def player_settings(game):
|
||||||
|
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
|
# Game Info Pages
|
||||||
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
|
def game_info(game, lang):
|
||||||
|
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
|
# List of supported games
|
||||||
|
@app.route('/games')
|
||||||
|
def games():
|
||||||
|
worlds = {}
|
||||||
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
|
return render_template("supportedGames.html", worlds=worlds)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
|
def tutorial(game, file, lang):
|
||||||
|
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/tutorial/')
|
||||||
|
def tutorial_landing():
|
||||||
|
worlds = {}
|
||||||
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
|
return render_template("tutorialLanding.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/faq/<string:lang>/')
|
||||||
|
def faq(lang):
|
||||||
|
return render_template("faq.html", lang=lang)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/glossary/<string:lang>/')
|
||||||
|
def terms(lang):
|
||||||
|
return render_template("glossary.html", lang=lang)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/seed/<suuid:seed>')
|
||||||
|
def view_seed(seed: UUID):
|
||||||
|
seed = Seed.get(id=seed)
|
||||||
|
if not seed:
|
||||||
|
abort(404)
|
||||||
|
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/new_room/<suuid:seed>')
|
||||||
|
def new_room(seed: UUID):
|
||||||
|
seed = Seed.get(id=seed)
|
||||||
|
if not seed:
|
||||||
|
abort(404)
|
||||||
|
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||||
|
commit()
|
||||||
|
return redirect(url_for("host_room", room=room.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log(path: str):
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, encoding="utf-8-sig") as log:
|
||||||
|
yield from log
|
||||||
|
else:
|
||||||
|
yield f"Logfile {path} does not exist. " \
|
||||||
|
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/log/<suuid:room>')
|
||||||
|
def display_log(room: UUID):
|
||||||
|
room = Room.get(id=room)
|
||||||
|
if room is None:
|
||||||
|
return abort(404)
|
||||||
|
if room.owner == session["_id"]:
|
||||||
|
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||||
|
return "Access Denied", 403
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||||
|
def host_room(room: UUID):
|
||||||
|
room: Room = Room.get(id=room)
|
||||||
|
if room is None:
|
||||||
|
return abort(404)
|
||||||
|
if request.method == "POST":
|
||||||
|
if room.owner == session["_id"]:
|
||||||
|
cmd = request.form["cmd"]
|
||||||
|
if cmd:
|
||||||
|
Command(room=room, commandtext=cmd)
|
||||||
|
commit()
|
||||||
|
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
# 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)
|
||||||
|
with db_session:
|
||||||
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/favicon.ico')
|
||||||
|
def favicon():
|
||||||
|
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||||
|
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/discord')
|
||||||
|
def discord():
|
||||||
|
return redirect("https://discord.gg/archipelago")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/datapackage')
|
||||||
|
@cache.cached()
|
||||||
|
def get_datapackage():
|
||||||
|
"""A pretty print version of /api/datapackage"""
|
||||||
|
from worlds import network_data_package
|
||||||
|
import json
|
||||||
|
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/index')
|
||||||
|
@app.route('/sitemap')
|
||||||
|
def get_sitemap():
|
||||||
|
available_games = []
|
||||||
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden:
|
||||||
|
available_games.append(game)
|
||||||
|
return render_template("siteMap.html", games=available_games)
|
||||||
@@ -27,7 +27,7 @@ class Room(db.Entity):
|
|||||||
seed = Required('Seed', index=True)
|
seed = Required('Seed', index=True)
|
||||||
multisave = Optional(buffer, lazy=True)
|
multisave = Optional(buffer, lazy=True)
|
||||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||||
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
|
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||||
tracker = Optional(UUID, index=True)
|
tracker = Optional(UUID, index=True)
|
||||||
last_port = Optional(int, default=lambda: 0)
|
last_port = Optional(int, default=lambda: 0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from Utils import __version__
|
from Utils import __version__, local_path
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
import yaml
|
import yaml
|
||||||
import json
|
import json
|
||||||
@@ -9,14 +9,13 @@ import typing
|
|||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import Options
|
import Options
|
||||||
|
|
||||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
|
||||||
|
|
||||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
"exclude_locations"}
|
"exclude_locations"}
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
target_folder = local_path("WebHostLib", "static", "generated")
|
||||||
|
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
|
||||||
|
|
||||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||||
data = {}
|
data = {}
|
||||||
@@ -49,6 +48,11 @@ def create():
|
|||||||
return list(default_value)
|
return list(default_value)
|
||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
|
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||||
|
if not option_type.__doc__:
|
||||||
|
return "Please document me!"
|
||||||
|
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||||
|
|
||||||
weighted_settings = {
|
weighted_settings = {
|
||||||
"baseOptions": {
|
"baseOptions": {
|
||||||
"description": "Generated by https://archipelago.gg/",
|
"description": "Generated by https://archipelago.gg/",
|
||||||
@@ -60,13 +64,17 @@ def create():
|
|||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
|
||||||
all_options = {**world.options, **Options.per_game_common_options}
|
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
||||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||||
|
file_data = f.read()
|
||||||
|
res = Template(file_data).render(
|
||||||
options=all_options,
|
options=all_options,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
dictify_range=dictify_range, default_converter=default_converter,
|
dictify_range=dictify_range, default_converter=default_converter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
del file_data
|
||||||
|
|
||||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
@@ -88,7 +96,7 @@ def create():
|
|||||||
game_options[option_name] = this_option = {
|
game_options[option_name] = this_option = {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"defaultValue": None,
|
"defaultValue": None,
|
||||||
"options": []
|
"options": []
|
||||||
}
|
}
|
||||||
@@ -110,18 +118,18 @@ def create():
|
|||||||
if option.default == "random":
|
if option.default == "random":
|
||||||
this_option["defaultValue"] = "random"
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
elif issubclass(option, Options.Range):
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "range",
|
"type": "range",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"defaultValue": option.default if hasattr(
|
"defaultValue": option.default if hasattr(
|
||||||
option, "default") and option.default != "random" else option.range_start,
|
option, "default") and option.default != "random" else option.range_start,
|
||||||
"min": option.range_start,
|
"min": option.range_start,
|
||||||
"max": option.range_end,
|
"max": option.range_end,
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasattr(option, "special_range_names"):
|
if issubclass(option, Options.SpecialRange):
|
||||||
game_options[option_name]["type"] = 'special_range'
|
game_options[option_name]["type"] = 'special_range'
|
||||||
game_options[option_name]["value_names"] = {}
|
game_options[option_name]["value_names"] = {}
|
||||||
for key, val in option.special_range_names.items():
|
for key, val in option.special_range_names.items():
|
||||||
@@ -131,22 +139,22 @@ def create():
|
|||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "items-list",
|
"type": "items-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif getattr(option, "verify_location_name", False):
|
elif getattr(option, "verify_location_name", False):
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "locations-list",
|
"type": "locations-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif hasattr(option, "valid_keys"):
|
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
||||||
if option.valid_keys:
|
if option.valid_keys:
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "custom-list",
|
"type": "custom-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"options": list(option.valid_keys),
|
"options": list(option.valid_keys),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
flask>=2.1.2
|
flask>=2.2.2
|
||||||
pony>=0.7.16
|
pony>=0.7.16
|
||||||
waitress>=2.1.1
|
waitress>=2.1.2
|
||||||
flask-caching>=1.11.1
|
Flask-Caching>=2.0.1
|
||||||
Flask-Compress>=1.12
|
Flask-Compress>=1.12
|
||||||
Flask-Limiter>=2.4.6
|
Flask-Limiter>=2.6.2
|
||||||
bokeh>=2.4.3
|
bokeh>=2.4.3
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ If you are ready to start randomizing games, or want to start playing your favor
|
|||||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||||
any questions you might have.
|
any questions you might have.
|
||||||
|
|
||||||
|
## What are some common terms I should know?
|
||||||
|
|
||||||
|
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||||
|
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||||
|
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||||
|
|
||||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||||
|
|
||||||
The best way to get started is to take a look at our code on GitHub
|
The best way to get started is to take a look at our code on GitHub
|
||||||
|
|||||||
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Multiworld Glossary
|
||||||
|
|
||||||
|
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
|
||||||
|
This document serves as a lookup for common terms that may be used by users in the community or in various other
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
## Item
|
||||||
|
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
|
||||||
|
upgrade, a spell, or any other potential receivable for your game.
|
||||||
|
|
||||||
|
## Location
|
||||||
|
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
|
||||||
|
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
|
||||||
|
contain items in your game.
|
||||||
|
|
||||||
|
## Check
|
||||||
|
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
|
||||||
|
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
|
||||||
|
randomized locations as checks.
|
||||||
|
|
||||||
|
## Slot
|
||||||
|
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
|
||||||
|
or "worlds", created. Each name must be unique as these are used to identify the slot user.
|
||||||
|
|
||||||
|
## World
|
||||||
|
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
|
||||||
|
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
|
||||||
|
Archipelago allows multiple players to connect to the same slot; then those players can share a world
|
||||||
|
and complete it cooperatively. For games with native cooperative play, you can also play together and
|
||||||
|
share a world that way, usually with only one player connected to the multiworld.
|
||||||
|
* On the programming side, a world typically represents the package that integrates Archipelago with a
|
||||||
|
particular game. For example this could be the entire `worlds/factorio` directory.
|
||||||
|
|
||||||
|
## RNG
|
||||||
|
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
|
||||||
|
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
|
||||||
|
game worlds will have access to it.
|
||||||
|
|
||||||
|
## Seed
|
||||||
|
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
|
||||||
|
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
|
||||||
|
single seed. Using the same seed results in the random placement being the same.
|
||||||
|
|
||||||
|
## Room
|
||||||
|
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
|
||||||
|
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
|
||||||
|
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
|
||||||
|
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
|
||||||
|
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
|
||||||
|
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
|
||||||
|
perfectly safe to share for racing purposes.
|
||||||
|
|
||||||
|
## Logic
|
||||||
|
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
|
||||||
|
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
|
||||||
|
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
|
||||||
|
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
|
||||||
|
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
|
||||||
|
when sending out an item they obtained this way.
|
||||||
|
|
||||||
|
## Progression
|
||||||
|
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
|
||||||
|
|
||||||
|
## Trash
|
||||||
|
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
|
||||||
|
or useless. These items can be very useful depending on the player but are never very important and as such are usually
|
||||||
|
termed trash.
|
||||||
|
|
||||||
|
## Burger King / BK Mode
|
||||||
|
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
|
||||||
|
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
|
||||||
|
items from other players.
|
||||||
|
|
||||||
|
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
|
||||||
|
things that would be "out of logic" by the generation.
|
||||||
|
|
||||||
|
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
|
||||||
|
continue to play, aside from doing something like killing enemies for experience or money.
|
||||||
|
|
||||||
|
## Sphere
|
||||||
|
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
|
||||||
|
to see what the players are able to reach with their current items. Any location that is reachable with the current
|
||||||
|
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
|
||||||
|
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
|
||||||
|
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
|
||||||
|
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
|
||||||
|
all players have completed their goal.
|
||||||
|
|
||||||
|
## Scouts / Scouting
|
||||||
|
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
|
||||||
|
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
|
||||||
|
client letting the players know what the exact item is, since if the item was for that game it would know but the item
|
||||||
|
being foreign is a lot harder to represent visually.
|
||||||
|
|
||||||
53
WebHostLib/static/assets/glossary.js
Normal file
53
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
window.addEventListener('load', () => {
|
||||||
|
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const ajax = new XMLHttpRequest();
|
||||||
|
ajax.onreadystatechange = () => {
|
||||||
|
if (ajax.readyState !== 4) { return; }
|
||||||
|
if (ajax.status === 404) {
|
||||||
|
reject("Sorry, the glossary page is not available in that language yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ajax.status !== 200) {
|
||||||
|
reject("Something went wrong while loading the glossary.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(ajax.responseText);
|
||||||
|
};
|
||||||
|
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||||
|
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||||
|
ajax.send();
|
||||||
|
}).then((results) => {
|
||||||
|
// Populate page with HTML generated from markdown
|
||||||
|
showdown.setOption('tables', true);
|
||||||
|
showdown.setOption('strikethrough', true);
|
||||||
|
showdown.setOption('literalMidWordUnderscores', true);
|
||||||
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
|
adjustHeaderWidth();
|
||||||
|
|
||||||
|
// Reset the id of all header divs to something nicer
|
||||||
|
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||||
|
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||||
|
for (let i=0; i < headers.length; i++){
|
||||||
|
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||||
|
headers[i].setAttribute('id', headerId);
|
||||||
|
headers[i].addEventListener('click', () =>
|
||||||
|
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
|
if (scrollTargetIndex > -1) {
|
||||||
|
try{
|
||||||
|
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||||
|
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||||
|
} catch(error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
tutorialWrapper.innerHTML =
|
||||||
|
`<h2>This page is out of logic!</h2>
|
||||||
|
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const url = window.location;
|
|
||||||
setInterval(() => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
|
||||||
|
|
||||||
// Update item and location trackers
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
|
|
||||||
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
});
|
|
||||||
@@ -102,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
// td Left
|
// td Left
|
||||||
const tdl = document.createElement('td');
|
const tdl = document.createElement('td');
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
|
label.textContent = `${settings[setting].displayName}: `;
|
||||||
label.setAttribute('for', setting);
|
label.setAttribute('for', setting);
|
||||||
label.setAttribute('data-tooltip', settings[setting].description);
|
|
||||||
label.innerText = `${settings[setting].displayName}:`;
|
const questionSpan = document.createElement('span');
|
||||||
|
questionSpan.classList.add('interactive');
|
||||||
|
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||||
|
questionSpan.innerText = '(?)';
|
||||||
|
|
||||||
|
label.appendChild(questionSpan);
|
||||||
tdl.appendChild(label);
|
tdl.appendChild(label);
|
||||||
tr.appendChild(tdl);
|
tr.appendChild(tdl);
|
||||||
|
|
||||||
|
|||||||
82
WebHostLib/static/assets/trackers/playerTracker.js
Normal file
82
WebHostLib/static/assets/trackers/playerTracker.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
window.addEventListener('load', () => {
|
||||||
|
// Reload tracker
|
||||||
|
const update = () => {
|
||||||
|
const room = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||||
|
|
||||||
|
const request = new Request('/api/tracker/' + room);
|
||||||
|
|
||||||
|
fetch(request)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// update locations blocks
|
||||||
|
for (const location of data.checked_locations) {
|
||||||
|
document.getElementById(location).classList.add('acquired');
|
||||||
|
}
|
||||||
|
// update totals checks done
|
||||||
|
let total_checks_ele = document.getElementById('total-checks');
|
||||||
|
const total_checks = document.getElementsByClassName('location').length;
|
||||||
|
let checks_done = data.checked_locations.length;
|
||||||
|
total_checks_ele.innerText = 'Total Checks Done: ' + checks_done + '/' + total_checks;
|
||||||
|
// update item and icons blocks
|
||||||
|
// update icons block
|
||||||
|
if (data.icons.length > 0) {
|
||||||
|
for (let item in data.icons) {
|
||||||
|
if (data.progressive_names.length > 0) {
|
||||||
|
for (let item_category in data.progressive_names) {
|
||||||
|
let i = 0;
|
||||||
|
for (let current_item in current_name) {
|
||||||
|
if (current_item === item) {
|
||||||
|
let doc_item = document.getElementById(item_category)
|
||||||
|
doc_item.children[0].src = data.icons[item];
|
||||||
|
if (item in data.items_received) {
|
||||||
|
doc_item.children[0].classList.add('acquired');
|
||||||
|
doc_item.children[1].innerText = item_category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (item in data.items_received) {
|
||||||
|
let current_item = document.getElementById(item);
|
||||||
|
current_item.children[0].classList.add('acquired');
|
||||||
|
current_item.children[0].src = data.icons[item];
|
||||||
|
current_item.children[1].innerText = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const item in data.items_received) {
|
||||||
|
if (document.getElementById(item)) {
|
||||||
|
let current_item = document.getElementById(item);
|
||||||
|
current_item.innerText = item + data.items_received[item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
setInterval(update, 30000);
|
||||||
|
|
||||||
|
|
||||||
|
// Collapsible regions section
|
||||||
|
const regions = document.getElementsByClassName('regions-column');
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
let region_name = regions[i].id;
|
||||||
|
|
||||||
|
const tab_header = document.getElementById(region_name+'-header');
|
||||||
|
const locations = document.getElementById(region_name+'-locations');
|
||||||
|
// toggle locations display
|
||||||
|
regions[i].addEventListener('click', function(event) {
|
||||||
|
if (tab_header.innerHTML.includes("▼")) {
|
||||||
|
locations.classList.remove('hidden');
|
||||||
|
// change header text
|
||||||
|
tab_header.innerHTML = tab_header.innerHTML.replace('▼', '▲');
|
||||||
|
} else {
|
||||||
|
locations.classList.add('hidden');
|
||||||
|
// change header text
|
||||||
|
tab_header.innerHTML = tab_header.innerHTML.replace('▲', '▼');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
82
WebHostLib/static/assets/trackers/zeldaKeysTracker.js
Normal file
82
WebHostLib/static/assets/trackers/zeldaKeysTracker.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
window.addEventListener('load', () => {
|
||||||
|
// Reload tracker
|
||||||
|
const update = () => {
|
||||||
|
const room = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||||
|
|
||||||
|
const request = new Request('/api/tracker/' + room);
|
||||||
|
|
||||||
|
fetch(request)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// update locations blocks
|
||||||
|
for (const location of data.checked_locations) {
|
||||||
|
document.getElementById(location).classList.add('acquired');
|
||||||
|
}
|
||||||
|
// update totals checks done
|
||||||
|
let total_checks_ele = document.getElementById('total-checks');
|
||||||
|
const total_checks = document.getElementsByClassName('location').length;
|
||||||
|
let checks_done = data.checked_locations.length;
|
||||||
|
total_checks_ele.innerText = 'Total Checks Done: ' + checks_done + '/' + total_checks;
|
||||||
|
// update item and icons blocks
|
||||||
|
// update icons block
|
||||||
|
if (data.icons.length > 0) {
|
||||||
|
for (let item in data.icons) {
|
||||||
|
if (data.progressive_names.length > 0) {
|
||||||
|
for (let item_category in data.progressive_names) {
|
||||||
|
let i = 0;
|
||||||
|
for (let current_item in current_name) {
|
||||||
|
if (current_item === item) {
|
||||||
|
let doc_item = document.getElementById(item_category)
|
||||||
|
doc_item.children[0].src = data.icons[item];
|
||||||
|
if (item in data.items_received) {
|
||||||
|
doc_item.children[0].classList.add('acquired');
|
||||||
|
doc_item.children[1].innerText = item_category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (item in data.items_received) {
|
||||||
|
let current_item = document.getElementById(item);
|
||||||
|
current_item.children[0].classList.add('acquired');
|
||||||
|
current_item.children[0].src = data.icons[item];
|
||||||
|
current_item.children[1].innerText = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const item in data.items_received) {
|
||||||
|
if (document.getElementById(item)) {
|
||||||
|
let current_item = document.getElementById(item);
|
||||||
|
current_item.innerText = item + data.items_received[item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
setInterval(update, 30000);
|
||||||
|
|
||||||
|
|
||||||
|
// Collapsible regions section
|
||||||
|
const regions = document.getElementsByClassName('regions-column');
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
let region_name = regions[i].id;
|
||||||
|
|
||||||
|
const tab_header = document.getElementById(region_name+'-header');
|
||||||
|
const locations = document.getElementById(region_name+'-locations');
|
||||||
|
// toggle locations display
|
||||||
|
regions[i].addEventListener('click', function(event) {
|
||||||
|
if (tab_header.innerHTML.includes("▼")) {
|
||||||
|
locations.classList.remove('hidden');
|
||||||
|
// change header text
|
||||||
|
tab_header.innerHTML = tab_header.innerHTML.replace('▼', '▲');
|
||||||
|
} else {
|
||||||
|
locations.classList.add('hidden');
|
||||||
|
// change header text
|
||||||
|
tab_header.innerHTML = tab_header.innerHTML.replace('▲', '▼');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
|
|||||||
games.forEach((game) => {
|
games.forEach((game) => {
|
||||||
const gameTitle = document.createElement('h2');
|
const gameTitle = document.createElement('h2');
|
||||||
gameTitle.innerText = game.gameTitle;
|
gameTitle.innerText = game.gameTitle;
|
||||||
|
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||||
tutorialDiv.appendChild(gameTitle);
|
tutorialDiv.appendChild(gameTitle);
|
||||||
|
|
||||||
game.tutorials.forEach((tutorial) => {
|
game.tutorials.forEach((tutorial) => {
|
||||||
@@ -65,6 +66,15 @@ window.addEventListener('load', () => {
|
|||||||
showError();
|
showError();
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we are on an anchor when coming in, and scroll to it.
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash) {
|
||||||
|
const offset = 128; // To account for navbar banner at top of page.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||||
|
window.scrollTo(rect.left, rect.top - offset);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||||
ajax.send();
|
ajax.send();
|
||||||
|
|||||||
@@ -56,7 +56,3 @@
|
|||||||
#file-input{
|
#file-input{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive{
|
|
||||||
color: #ffef00;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,3 +105,7 @@ h5, h6{
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background-color: #ffff00;
|
background-color: #ffff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.interactive{
|
||||||
|
color: #ffef00;
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
/* Base styles for the element that has a tooltip */
|
/* Base styles for the element that has a tooltip */
|
||||||
[data-tooltip], .tooltip {
|
[data-tooltip], .tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles for the entire tooltip */
|
/* Base styles for the entire tooltip */
|
||||||
@@ -55,14 +54,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
|
|
||||||
/** Content styles */
|
/** Content styles */
|
||||||
.tooltip:after, [data-tooltip]:after {
|
.tooltip:after, [data-tooltip]:after {
|
||||||
|
width: 260px;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
width: 160px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
background-color: hsla(0, 0%, 20%, 0.9);
|
background-color: hsla(0, 0%, 20%, 0.9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
|
white-space: pre-wrap;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|||||||
150
WebHostLib/static/styles/trackers/playerTracker.css
Normal file
150
WebHostLib/static/styles/trackers/playerTracker.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* CSS Overrides */
|
||||||
|
.dirt-wrapper{
|
||||||
|
background-color: #897249;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirt-wrapper h1{}
|
||||||
|
|
||||||
|
.grass-wrapper{
|
||||||
|
background-color: #3fb24a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grass-wrapper h1{}
|
||||||
|
|
||||||
|
.grassFlowers-wrapper{
|
||||||
|
background-color: #3fb24a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grassFlowers-wrapper h1{}
|
||||||
|
|
||||||
|
.ice-wrapper{
|
||||||
|
background-color: #afe0ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ice-wrapper h1{}
|
||||||
|
|
||||||
|
.jungle-wrapper{
|
||||||
|
background-color: #2a7808;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jungle-wrapper h1{}
|
||||||
|
|
||||||
|
.ocean-wrapper{
|
||||||
|
background-color: #3667b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocean-wrapper h1{}
|
||||||
|
|
||||||
|
.partyTime-wrapper{
|
||||||
|
background-color: #3a0f69;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partyTime-wrapper h1{}
|
||||||
|
|
||||||
|
|
||||||
|
/* Actual Styles */
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 5px;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 1px 1px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-keys-tracker{
|
||||||
|
width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#items-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#items-container div{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container{
|
||||||
|
display: absolute;
|
||||||
|
height: 75px;
|
||||||
|
width: 75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-text{
|
||||||
|
position: relative;
|
||||||
|
align-items: bottom;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon{
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
left: 15px;
|
||||||
|
max-width: 45px;
|
||||||
|
max-height: 45px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-checks{
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-container{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: 50px;
|
||||||
|
margin-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location.acquired{
|
||||||
|
text-decoration: line-through;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regions-container{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
padding: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regions-header{
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link{
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 30px;
|
||||||
|
background-color: lightgrey;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
@@ -51,6 +51,17 @@ table.dataTable{
|
|||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table.dataTable img.icon{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 60px;
|
||||||
|
max-height: 60px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.dataTable img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
table.dataTable thead{
|
table.dataTable thead{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -1,54 +1,104 @@
|
|||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from itertools import cycle
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from math import tau
|
from math import tau
|
||||||
|
import typing
|
||||||
|
|
||||||
from bokeh.embed import components
|
from bokeh.embed import components
|
||||||
from bokeh.palettes import Dark2_8 as palette
|
from bokeh.models import HoverTool
|
||||||
from bokeh.plotting import figure, ColumnDataSource
|
from bokeh.plotting import figure, ColumnDataSource
|
||||||
from bokeh.resources import INLINE
|
from bokeh.resources import INLINE
|
||||||
|
from bokeh.colors import RGB
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .models import Room
|
from .models import Room
|
||||||
|
|
||||||
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
def get_db_data():
|
|
||||||
|
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
|
||||||
games_played = defaultdict(Counter)
|
games_played = defaultdict(Counter)
|
||||||
total_games = Counter()
|
total_games = Counter()
|
||||||
cutoff = date.today()-timedelta(days=30000)
|
cutoff = date.today()-timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
total_games[slot.game] += 1
|
if slot.game in known_games:
|
||||||
games_played[room.creation_time.date()][slot.game] += 1
|
total_games[slot.game] += 1
|
||||||
|
games_played[room.creation_time.date()][slot.game] += 1
|
||||||
return total_games, games_played
|
return total_games, games_played
|
||||||
|
|
||||||
|
|
||||||
@app.route('/stats')
|
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
||||||
@cache.memoize(timeout=60*60) # regen once per hour should be plenty
|
colors = []
|
||||||
def stats():
|
# colors_needed +1 to prevent first and last color being too close to each other
|
||||||
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
colors_needed += 1
|
||||||
y_axis_label="Games Played", sizing_mode="scale_both", width=500, height=500)
|
|
||||||
|
|
||||||
total_games, games_played = get_db_data()
|
for x in range(0, 361, 360 // colors_needed):
|
||||||
|
# a bit of noise on value to add some luminosity difference
|
||||||
|
colors.append(RGB(*(val * 255 for val in hsv_to_rgb(x / 360, 0.8, 0.8 + (x / 1800)))))
|
||||||
|
|
||||||
|
# splice colors for maximum hue contrast.
|
||||||
|
colors = colors[::2] + colors[1::2]
|
||||||
|
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
||||||
|
game: str, color: RGB) -> figure:
|
||||||
|
occurences = []
|
||||||
|
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||||
|
for day in days:
|
||||||
|
occurences.append(all_games_data[day][game])
|
||||||
|
data = {
|
||||||
|
"days": [datetime.combine(day, datetime.min.time()) for day in days],
|
||||||
|
"played": occurences
|
||||||
|
}
|
||||||
|
|
||||||
|
plot = figure(
|
||||||
|
title=f"{game} Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
||||||
|
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500,
|
||||||
|
toolbar_location=None, tools="",
|
||||||
|
# setting legend to False seems broken in bokeh currently?
|
||||||
|
# legend=False
|
||||||
|
)
|
||||||
|
|
||||||
|
hover = HoverTool(tooltips=[("Date:", "@days{%F}"), ("Played:", "@played")], formatters={"@days": "datetime"})
|
||||||
|
plot.add_tools(hover)
|
||||||
|
plot.vbar(x="days", top="played", legend_label=game, color=color, source=ColumnDataSource(data=data), width=1)
|
||||||
|
return plot
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/stats')
|
||||||
|
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty
|
||||||
|
def stats():
|
||||||
|
from worlds import network_data_package
|
||||||
|
known_games = set(network_data_package["games"])
|
||||||
|
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
||||||
|
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
|
||||||
|
|
||||||
|
total_games, games_played = get_db_data(known_games)
|
||||||
days = sorted(games_played)
|
days = sorted(games_played)
|
||||||
|
|
||||||
cyc_palette = cycle(palette)
|
color_palette = get_color_palette(len(total_games))
|
||||||
|
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||||
|
|
||||||
for game in sorted(total_games):
|
for game in sorted(total_games):
|
||||||
occurences = []
|
occurences = []
|
||||||
for day in days:
|
for day in days:
|
||||||
occurences.append(games_played[day][game])
|
occurences.append(games_played[day][game])
|
||||||
plot.line([datetime.combine(day, datetime.min.time()) for day in days],
|
plot.line([datetime.combine(day, datetime.min.time()) for day in days],
|
||||||
occurences, legend_label=game, line_width=2, color=next(cyc_palette))
|
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
||||||
|
|
||||||
total = sum(total_games.values())
|
total = sum(total_games.values())
|
||||||
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||||
sizing_mode="scale_both", width=500, height=500)
|
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||||
pie.axis.visible = False
|
pie.axis.visible = False
|
||||||
|
pie.xgrid.visible = False
|
||||||
|
pie.ygrid.visible = False
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"games": [],
|
"games": [],
|
||||||
@@ -65,12 +115,15 @@ def stats():
|
|||||||
current_angle += angle
|
current_angle += angle
|
||||||
data["end_angles"].append(current_angle)
|
data["end_angles"].append(current_angle)
|
||||||
|
|
||||||
data["colors"] = [element[1] for element in sorted((game, color) for game, color in
|
data["colors"] = [game_to_color[game] for game in data["games"]]
|
||||||
zip(data["games"], cycle(palette)))]
|
|
||||||
pie.wedge(x=0.5, y=0.5, radius=0.5,
|
pie.wedge(x=0, y=0, radius=0.5,
|
||||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||||
source=ColumnDataSource(data=data), legend_field="games")
|
source=ColumnDataSource(data=data), legend_field="games")
|
||||||
|
|
||||||
script, charts = components((plot, pie))
|
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
|
||||||
|
if total_games[game] > 1]
|
||||||
|
|
||||||
|
script, charts = components((plot, pie, *per_game_charts))
|
||||||
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
|
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
|
||||||
chart_data=script, charts=charts)
|
chart_data=script, charts=charts)
|
||||||
|
|||||||
@@ -41,12 +41,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="forfeit_mode">Forfeit Permission:</label>
|
<label for="forfeit_mode">Forfeit Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="A forfeit releases all remaining items from the locations
|
</span>
|
||||||
in your world.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="forfeit_mode" id="forfeit_mode">
|
<select name="forfeit_mode" id="forfeit_mode">
|
||||||
@@ -63,12 +62,11 @@
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="collect_mode">Collect Permission:</label>
|
<label for="collect_mode">Collect Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="A collect releases all of your remaining items to you
|
</span>
|
||||||
from across the multiworld.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="collect_mode" id="collect_mode">
|
<select name="collect_mode" id="collect_mode">
|
||||||
@@ -85,12 +83,11 @@
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="remaining_mode">Remaining Permission:</label>
|
<label for="remaining_mode">Remaining Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Remaining lists all items still in your world by name only."
|
</span>
|
||||||
>(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="remaining_mode" id="remaining_mode">
|
<select name="remaining_mode" id="remaining_mode">
|
||||||
@@ -106,11 +103,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="item_cheat">Item Cheat:</label>
|
<label for="item_cheat">Item Cheat:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows players to use the !getitem command.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows players to use the !getitem command.">(?)
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="item_cheat" id="item_cheat">
|
<select name="item_cheat" id="item_cheat">
|
||||||
@@ -131,12 +128,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="hint_cost"> Hint Cost:</label>
|
<label for="hint_cost"> Hint Cost:
|
||||||
<span
|
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="After gathering this many checks, players can !hint <itemname>
|
</span>
|
||||||
to get the location of that hint item.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="hint_cost" id="hint_cost">
|
<select name="hint_cost" id="hint_cost">
|
||||||
@@ -150,11 +146,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="server_password">Server Password:</label>
|
<label for="server_password">Server Password:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input id="server_password" name="server_password">
|
<input id="server_password" name="server_password">
|
||||||
@@ -162,23 +158,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="plando_options">Plando Options:</label>
|
Plando Options:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" name="plando_bosses" value="bosses" checked>
|
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
|
||||||
<label for="plando_bosses">Bosses</label><br>
|
<label for="plando_bosses">Bosses</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_items" value="items" checked>
|
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
|
||||||
<label for="plando_items">Items</label><br>
|
<label for="plando_items">Items</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_connections" value="connections" checked>
|
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
|
||||||
<label for="plando_connections">Connections</label><br>
|
<label for="plando_connections">Connections</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_texts" value="texts" checked>
|
<input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked>
|
||||||
<label for="plando_texts">Text</label>
|
<label for="plando_texts">Text</label>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
17
WebHostLib/templates/glossary.html
Normal file
17
WebHostLib/templates/glossary.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% include 'header/grassHeader.html' %}
|
||||||
|
<title>Glossary</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||||
|
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/glossary.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div id="glossary-wrapper" data-lang="{{ lang }}" class="markdown">
|
||||||
|
<!-- Content generated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Multiworld {{ room.id|suuid }}</title>
|
<title>Multiworld {{ room.id|suuid }}</title>
|
||||||
|
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@
|
|||||||
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||||
<br />
|
<br />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
This room will be closed after {{ room.timeout//60//60 }} hours of inactivity. Should you wish to continue
|
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||||
later,
|
Should you wish to continue later,
|
||||||
you can simply refresh this page and the server will be started again.<br>
|
anyone can simply refresh this page and the server will resume.<br>
|
||||||
{% if room.last_port %}
|
{% if room.last_port %}
|
||||||
You can connect to this room by using <span class="interactive"
|
You can connect to this room by using <span class="interactive"
|
||||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
-
|
-
|
||||||
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
|
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
|
||||||
-
|
-
|
||||||
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
|
|
||||||
-
|
|
||||||
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
|
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
|
||||||
-
|
-
|
||||||
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>
|
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}"/>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
|
||||||
<table id="inventory-table">
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ bow_url }}" class="{{ 'acquired' if bow_acquired }}" /></td>
|
|
||||||
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
|
|
||||||
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ sword_url }}" class="{{ 'acquired' if sword_acquired }}" /></td>
|
|
||||||
<td><img src="{{ shield_url }}" class="{{ 'acquired' if shield_acquired }}" /></td>
|
|
||||||
<td><img src="{{ mail_url }}" class="acquired" /></td>
|
|
||||||
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
|
|
||||||
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table id="location-table">
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
|
|
||||||
{% if key_locations and "Universal" not in key_locations %}
|
|
||||||
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
|
|
||||||
{% endif %}
|
|
||||||
{% if big_key_locations %}
|
|
||||||
<th><img src="{{ icons["Big Key"] }}" /></th>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% for area in sp_areas %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ area }}</td>
|
|
||||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
|
||||||
{% if key_locations and "Universal" not in key_locations %}
|
|
||||||
<td class="counter">
|
|
||||||
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
{% if big_key_locations %}
|
|
||||||
<td>
|
|
||||||
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -40,14 +40,17 @@
|
|||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game in ["A Link to the Past", "Secret of Evermore", "Super Metroid", "SMZ3"] %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
|
{% elif patch.game == "Dark Souls III" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download JSON File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<li><a href="/user-content">User Content</a></li>
|
<li><a href="/user-content">User Content</a></li>
|
||||||
<li><a href="/weighted-settings">Weighted Settings Page</a></li>
|
<li><a href="/weighted-settings">Weighted Settings Page</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>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Game Info Pages</h2>
|
<h2>Game Info Pages</h2>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Player Settings</title>
|
<title>Supported Games</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -10,15 +10,21 @@
|
|||||||
{% include 'header/oceanHeader.html' %}
|
{% include 'header/oceanHeader.html' %}
|
||||||
<div id="games" class="markdown">
|
<div id="games" class="markdown">
|
||||||
<h1>Currently Supported Games</h1>
|
<h1>Currently Supported Games</h1>
|
||||||
{% for game_name, world in worlds.items() | sort(attribute=0) %}
|
{% for game_name in worlds | title_sorted %}
|
||||||
|
{% set world = worlds[game_name] %}
|
||||||
<h2>{{ game_name }}</h2>
|
<h2>{{ game_name }}</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ world.__doc__ | default("No description provided.", true) }}<br />
|
{{ world.__doc__ | default("No description provided.", true) }}<br />
|
||||||
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
|
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
|
||||||
|
{% if world.web.tutorials %}
|
||||||
<span class="link-spacer">|</span>
|
<span class="link-spacer">|</span>
|
||||||
|
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
|
||||||
|
{% endif %}
|
||||||
{% if world.web.settings_page is string %}
|
{% if world.web.settings_page is string %}
|
||||||
|
<span class="link-spacer">|</span>
|
||||||
<a href="{{ world.web.settings_page }}">Settings Page</a>
|
<a href="{{ world.web.settings_page }}">Settings Page</a>
|
||||||
{% elif world.web.settings_page %}
|
{% elif world.web.settings_page %}
|
||||||
|
<span class="link-spacer">|</span>
|
||||||
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
|
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if world.web.bug_report_page %}
|
{% if world.web.bug_report_page %}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<div id="tracker-header-bar">
|
<div id="tracker-header-bar">
|
||||||
<input placeholder="Search" id="search"/>
|
<input placeholder="Search" id="search"/>
|
||||||
<span class="info">This tracker will automatically update itself periodically.</span>
|
<span class="info">This tracker will automatically update itself periodically.</span>
|
||||||
|
<a href="/tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
|
||||||
|
Go to Styled Tracker
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="table non-unique-item-table">
|
<table class="table non-unique-item-table">
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/minecraftTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/minecraftTracker.js') }}"></script>
|
||||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<title>Multiworld Tracker</title>
|
<title>Multiworld Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/trackers/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackers/tracker.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{%- for player, items in players.items() -%}
|
{%- for player, items in players.items() -%}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
{%- if (team, loop.index) in video -%}
|
{%- if (team, loop.index) in video -%}
|
||||||
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{%- for player, checks in players.items() -%}
|
{%- for player, checks in players.items() -%}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||||
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||||
{%- for area in ordered_areas -%}
|
{%- for area in ordered_areas -%}
|
||||||
99
WebHostLib/templates/trackers/playerTracker.html
Normal file
99
WebHostLib/templates/trackers/playerTracker.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% block head %}
|
||||||
|
<!--suppress XmlDuplicatedId -->
|
||||||
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/playerTracker.css') }}"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/tooltip.css') }}"/>
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/playerTracker.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div id="tracker-wrapper" class="{{ theme }}-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
|
||||||
|
<a href="/generic_tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
|
||||||
|
Go to Generic Tracker
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if icons %}
|
||||||
|
|
||||||
|
{% block icons_render %}
|
||||||
|
|
||||||
|
<h1>Items</h1>
|
||||||
|
<div id="items-container">
|
||||||
|
{%- for item in icons %}
|
||||||
|
<div class="image-container tooltip" id="{{ item }}" data-tooltip="{{ item }}">
|
||||||
|
<img
|
||||||
|
src="{{ icons[item] }}"
|
||||||
|
class="icon tooltip {{ 'acquired' if item in received_items }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% block item_names_render %}
|
||||||
|
<h1 class="items-header">Items</h1>
|
||||||
|
<div class="items-container">
|
||||||
|
{%- for item in received_items|sort -%}
|
||||||
|
<div class="item" id="{{ item }}">
|
||||||
|
{{ item }}
|
||||||
|
{% if all_progression_items[item] > 1 %}
|
||||||
|
{{ received_items[item] }}
|
||||||
|
{% else %}
|
||||||
|
✔
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{# div for total checks done as percentage. Probably needs to be put somewhere else but I liked how it looked here #}
|
||||||
|
<div class="total-checks" id="total-checks">
|
||||||
|
Total Checks Done: {{ checked_locations|length }}/{{ locations|length }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if regions %}
|
||||||
|
|
||||||
|
{% block regions_render %}
|
||||||
|
|
||||||
|
<div class="regions-container">
|
||||||
|
{% for region in regions %}
|
||||||
|
<div class="regions-column" id="{{ region }}">
|
||||||
|
<h1 class="regions-header" id="{{ region }}-header">{{ region }} ▼ {{ checks_done[region]|length }} / {{ regions[region]|length }}</h1>
|
||||||
|
<div class="location-column hidden" id="{{ region }}-locations">
|
||||||
|
{%- for location in regions[region] %}
|
||||||
|
<div class="location {{ 'acquired' if location in checked_locations }}" id="{{ location }}">{{ location }}</div>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% block locations_render %}
|
||||||
|
|
||||||
|
<h1>Locations</h1>
|
||||||
|
<div class="locations-container" id="locations-container">
|
||||||
|
{% for location in locations %}
|
||||||
|
<div class="location {{ 'acquired' if name in checked_locations }}" id="{{ location }}">
|
||||||
|
{{ location }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/supermetroidTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/supermetroidTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/supermetroidTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/supermetroidTracker.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/timespinnerTracker.css') }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/timespinnerTracker.css') }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/timespinnerTracker.js') }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/timespinnerTracker.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
77
WebHostLib/templates/trackers/zeldaKeysTracker.html
Normal file
77
WebHostLib/templates/trackers/zeldaKeysTracker.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% block head %}
|
||||||
|
<!--suppress XmlDuplicatedId -->
|
||||||
|
<title>{{ player_name }}'s Keys Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/trackers/playerTracker.css') }}"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/tooltip.css') }}" />
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/trackers/zeldaKeysTracker.js') }}"/></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{# this tracker is mostly similar to the generic player tracker but
|
||||||
|
also adds a table with the key and checks counts for each region in the middle #}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div id="tracker-wrapper" class="{{ theme }}-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
|
||||||
|
<a href="/generic_tracker/{{ room.tracker|suuid }}/{{ team }}/{{ player }}" class="button-link">
|
||||||
|
Go to Generic Tracker
|
||||||
|
</a>
|
||||||
|
<h1>Items</h1>
|
||||||
|
<div id="items-container">
|
||||||
|
{% for item in icons %}
|
||||||
|
{% if item not in ['Small Key', 'Big Key'] %}
|
||||||
|
<div class="image-container tooltip" id="{{ item }}" data-tooltip="{{ item }}">
|
||||||
|
<img
|
||||||
|
src="{{ icons[item] }}"
|
||||||
|
class="icon tooltip {{ 'acquired' if item in received_items }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-checks" id="total-checks">
|
||||||
|
Total Checks Done: {{ checked_locations|length }}/{{ locations|length }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="regions-column">
|
||||||
|
<tr class="keys-icons">
|
||||||
|
<td><img src="{{icons['Small Key']}}" class="icon tooltip acquired" id="small-key-icon"/></td>
|
||||||
|
<td><img src="{{icons['Big Key']}}" class="icon tooltip acquired" id="big-key-icon"/></td>
|
||||||
|
<td class="right-align">Total</td>
|
||||||
|
</tr>
|
||||||
|
{% for region in regions %}
|
||||||
|
<tr class="regions-column" id="{{ region }}">
|
||||||
|
<td id="{{ region }}-header">{{ region }} ▼</td>
|
||||||
|
{% if region in region_keys %}
|
||||||
|
{%- if region_keys[region]|length > 1 %}
|
||||||
|
<td class="smallkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
|
||||||
|
<td class="bigkeys">{{ received_items[region_keys[region][1]] if region_keys[region][1] in received_items else '-' }}</td>
|
||||||
|
{%- else %}
|
||||||
|
{% if 'Small Key' in region_keys[region][0] %}
|
||||||
|
<td class="smallkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
|
||||||
|
<td class="bigkeys">-</td>
|
||||||
|
{% else %}
|
||||||
|
<td class="smallkeys">-</td>
|
||||||
|
<td class="bigkeys">{{ received_items[region_keys[region][0]] if region_keys[region][0] in received_items else '-' }}</td>
|
||||||
|
{% endif %}
|
||||||
|
{%- endif%}
|
||||||
|
{% else %}
|
||||||
|
<td class="smallkeys">-</td>
|
||||||
|
<td class="bigkeys">-</td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="counter">{{ checks_done[region]|length }} / {{ regions[region]|length }}</td>
|
||||||
|
</tr>
|
||||||
|
<tbody class="locations hidden" id="{{ region }}-locations">
|
||||||
|
{% for location in regions[region] %}
|
||||||
|
<tr>
|
||||||
|
<td class="location {{ 'acquired' if location in checked_locations }}" id="{{ location }}">
|
||||||
|
{{ location }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import collections
|
import collections
|
||||||
import typing
|
import typing
|
||||||
from typing import Counter, Optional, Dict, Any, Tuple
|
from typing import Counter, Optional, Dict, Any, Tuple, Set, List, TYPE_CHECKING
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from werkzeug.exceptions import abort
|
from werkzeug.exceptions import abort
|
||||||
@@ -11,9 +11,53 @@ from worlds.alttp import Items
|
|||||||
from WebHostLib import app, cache, Room
|
from WebHostLib import app, cache, Room
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from MultiServer import get_item_name_from_id, Context
|
from MultiServer import get_item_name_from_id, Context
|
||||||
from NetUtils import SlotType
|
from NetUtils import SlotType
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerTracker:
|
||||||
|
"""This class will create a basic 'prettier' tracker for each world using their themes automatically. This
|
||||||
|
can be overridden to customize how it will appear. Can provide icons and custom regions. The html used is also
|
||||||
|
a jinja template that can be overridden if you want your tracker to look different in certain aspects. To render
|
||||||
|
icons and regions add dictionaries to the relevant attributes of the tracker_info. To customize the layout of
|
||||||
|
your icons you can create a new html in your world and extend playerTracker.html and overwrite the icons_render
|
||||||
|
block then change the tracker_info template attribute to your template."""
|
||||||
|
|
||||||
|
template: str = 'playerTracker.html'
|
||||||
|
icons: Dict[str, str] = {}
|
||||||
|
progressive_items: List[str] = []
|
||||||
|
progressive_names: Dict[str, List[str]] = {}
|
||||||
|
regions: Dict[str, List[str]] = {}
|
||||||
|
checks_done: Dict[str, Set[str]] = {}
|
||||||
|
room: Any
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
name: str
|
||||||
|
all_locations: Set[str]
|
||||||
|
checked_locations: Set[str]
|
||||||
|
all_prog_items: Counter[str]
|
||||||
|
items_received: Counter[str]
|
||||||
|
received_prog_items: Counter[str]
|
||||||
|
slot_data: Dict[any, any]
|
||||||
|
theme: str
|
||||||
|
|
||||||
|
region_keys: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def __init__(self, room: Any, team: int, player: int, name: str, all_locations: Set[str],
|
||||||
|
checked_locations: set, all_progression_items: Counter[str], items_received: Counter[str],
|
||||||
|
slot_data: Dict[any, any]):
|
||||||
|
self.room = room
|
||||||
|
self.team = team
|
||||||
|
self.player = player
|
||||||
|
self.name = name
|
||||||
|
self.all_locations = all_locations
|
||||||
|
self.checked_locations = checked_locations
|
||||||
|
self.all_prog_items = all_progression_items
|
||||||
|
self.items_received = items_received
|
||||||
|
self.slot_data = slot_data
|
||||||
|
|
||||||
|
|
||||||
alttp_icons = {
|
alttp_icons = {
|
||||||
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
|
||||||
"Red Shield": r"https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
|
"Red Shield": r"https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
|
||||||
@@ -288,7 +332,7 @@ def get_static_room_data(room: Room):
|
|||||||
|
|
||||||
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||||
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||||
# Team and player must be positive and greater than zero
|
# Team and player must be positive and greater than zero
|
||||||
if tracked_team < 0 or tracked_player < 1:
|
if tracked_team < 0 or tracked_player < 1:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -297,13 +341,78 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
if not room:
|
if not room:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Collect seed information and pare it down to a single player
|
player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, \
|
||||||
|
slot_data, games, player_name, display_icons = fill_tracker_data(room, tracked_team, tracked_player)
|
||||||
|
|
||||||
|
game_name = games[tracked_player]
|
||||||
|
# TODO move all games in game_specific_trackers to new system
|
||||||
|
if game_name in game_specific_trackers and not want_generic:
|
||||||
|
specific_tracker = game_specific_trackers.get(game_name, None)
|
||||||
|
return specific_tracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player,
|
||||||
|
player_name, seed_checks_in_area, lttp_checks_done, slot_data[tracked_player])
|
||||||
|
elif game_name in AutoWorldRegister.world_types and not want_generic:
|
||||||
|
return render_template(
|
||||||
|
"trackers/" + player_tracker.template,
|
||||||
|
all_progression_items=player_tracker.all_prog_items,
|
||||||
|
player=tracked_player,
|
||||||
|
team=tracked_team,
|
||||||
|
room=player_tracker.room,
|
||||||
|
player_name=player_tracker.name,
|
||||||
|
checked_locations=sorted(player_tracker.checked_locations),
|
||||||
|
locations=sorted(player_tracker.all_locations),
|
||||||
|
theme=player_tracker.theme,
|
||||||
|
icons=display_icons,
|
||||||
|
regions=player_tracker.regions,
|
||||||
|
checks_done=player_tracker.checks_done,
|
||||||
|
region_keys=player_tracker.region_keys
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return __renderGenericTracker(multisave, room, player_tracker.all_locations, inventory, tracked_team, tracked_player, player_name, seed_checks_in_area, lttp_checks_done)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
||||||
|
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracker_icons_and_regions(player_tracker: PlayerTracker) -> Dict[str, str]:
|
||||||
|
"""this function allows multiple icons to be used for the same item but it does require the world to submit both
|
||||||
|
a progressive_items list and the icons dict together"""
|
||||||
|
display_icons: Dict[str, str] = {}
|
||||||
|
if player_tracker.progressive_names and player_tracker.icons:
|
||||||
|
for item in player_tracker.progressive_items:
|
||||||
|
if item in player_tracker.progressive_names:
|
||||||
|
level = min(player_tracker.items_received[item], len(player_tracker.progressive_names[item]) - 1)
|
||||||
|
display_name = player_tracker.progressive_names[item][level]
|
||||||
|
if display_name in player_tracker.icons:
|
||||||
|
display_icons[item] = player_tracker.icons[display_name]
|
||||||
|
else:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
else:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
else:
|
||||||
|
if player_tracker.progressive_items and player_tracker.icons:
|
||||||
|
for item in player_tracker.progressive_items:
|
||||||
|
display_icons[item] = player_tracker.icons[item]
|
||||||
|
|
||||||
|
if player_tracker.regions:
|
||||||
|
for region in player_tracker.regions:
|
||||||
|
for location in region:
|
||||||
|
if location in player_tracker.checked_locations:
|
||||||
|
player_tracker.checks_done.setdefault(region, set()).add(location)
|
||||||
|
|
||||||
|
return display_icons
|
||||||
|
|
||||||
|
|
||||||
|
def fill_tracker_data(room: Room, tracked_team: int, tracked_player: int) -> Tuple:
|
||||||
|
"""Collect seed information and pare it down to a single player"""
|
||||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||||
player_name = names[tracked_team][tracked_player - 1]
|
player_name = names[tracked_team][tracked_player - 1]
|
||||||
location_to_area = player_location_to_area[tracked_player]
|
location_to_area = player_location_to_area[tracked_player]
|
||||||
inventory = collections.Counter()
|
inventory = collections.Counter()
|
||||||
checks_done = {loc_name: 0 for loc_name in default_locations}
|
lttp_checks_done = {loc_name: 0 for loc_name in default_locations}
|
||||||
|
|
||||||
# Add starting items to inventory
|
# Add starting items to inventory
|
||||||
starting_items = precollected_items[tracked_player]
|
starting_items = precollected_items[tracked_player]
|
||||||
@@ -321,6 +430,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
if tracked_player in group_members:
|
if tracked_player in group_members:
|
||||||
slots_aimed_at_player.add(group_id)
|
slots_aimed_at_player.add(group_id)
|
||||||
|
|
||||||
|
checked_locations = set()
|
||||||
# Add items to player inventory
|
# Add items to player inventory
|
||||||
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
|
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
|
||||||
# Skip teams and players not matching the request
|
# Skip teams and players not matching the request
|
||||||
@@ -332,383 +442,52 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
|||||||
item, recipient, flags = player_locations[location]
|
item, recipient, flags = player_locations[location]
|
||||||
if recipient in slots_aimed_at_player: # a check done for the tracked player
|
if recipient in slots_aimed_at_player: # a check done for the tracked player
|
||||||
attribute_item_solo(inventory, item)
|
attribute_item_solo(inventory, item)
|
||||||
|
|
||||||
if ms_player == tracked_player: # a check done by the tracked player
|
if ms_player == tracked_player: # a check done by the tracked player
|
||||||
checks_done[location_to_area[location]] += 1
|
lttp_checks_done[location_to_area[location]] += 1
|
||||||
checks_done["Total"] += 1
|
lttp_checks_done["Total"] += 1
|
||||||
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
|
checked_locations.add(lookup_any_location_id_to_name[location])
|
||||||
if specific_tracker and not want_generic:
|
|
||||||
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
prog_items = collections.Counter
|
||||||
seed_checks_in_area, checks_done, slot_data[tracked_player])
|
all_location_names = set()
|
||||||
else:
|
|
||||||
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
all_location_names = {lookup_any_location_id_to_name[id] for id in locations[tracked_player]}
|
||||||
seed_checks_in_area, checks_done)
|
prog_items = collections.Counter()
|
||||||
|
for player in locations:
|
||||||
|
for location in locations[player]:
|
||||||
|
item, recipient, flags = locations[player][location]
|
||||||
|
if recipient == player:
|
||||||
|
if flags & 1:
|
||||||
|
item_name = lookup_any_item_id_to_name[item]
|
||||||
|
prog_items[item_name] += 1
|
||||||
|
|
||||||
|
items_received = collections.Counter()
|
||||||
|
for id in inventory:
|
||||||
|
items_received[lookup_any_item_id_to_name[id]] = inventory[id]
|
||||||
|
|
||||||
|
player_tracker = PlayerTracker(
|
||||||
|
room,
|
||||||
|
tracked_team,
|
||||||
|
tracked_player,
|
||||||
|
player_name,
|
||||||
|
all_location_names,
|
||||||
|
checked_locations,
|
||||||
|
prog_items,
|
||||||
|
items_received,
|
||||||
|
slot_data[tracked_player]
|
||||||
|
)
|
||||||
|
|
||||||
|
# grab webworld and apply its theme to the tracker
|
||||||
|
webworld = AutoWorldRegister.world_types[games[tracked_player]].web
|
||||||
|
player_tracker.theme = webworld.theme
|
||||||
|
# allow the world to add information to the tracker class
|
||||||
|
webworld.modify_tracker(player_tracker)
|
||||||
|
display_icons = get_tracker_icons_and_regions(player_tracker)
|
||||||
|
|
||||||
|
return player_tracker, multisave, inventory, seed_checks_in_area, lttp_checks_done, slot_data, games, player_name, display_icons
|
||||||
|
|
||||||
|
|
||||||
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
|
||||||
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, player_name: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
# Note the presence of the triforce item
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
if game_state == 30:
|
|
||||||
inventory[106] = 1 # Triforce
|
|
||||||
|
|
||||||
# Progressive items need special handling for icons and class
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Sword": 94,
|
|
||||||
"Progressive Glove": 97,
|
|
||||||
"Progressive Bow": 100,
|
|
||||||
"Progressive Mail": 96,
|
|
||||||
"Progressive Shield": 95,
|
|
||||||
}
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Sword": [None, 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword'],
|
|
||||||
"Progressive Glove": [None, 'Power Glove', 'Titan Mitts'],
|
|
||||||
"Progressive Bow": [None, "Bow", "Silver Bow"],
|
|
||||||
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
|
|
||||||
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine which icon to use
|
|
||||||
display_data = {}
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
acquired = True
|
|
||||||
if not display_name:
|
|
||||||
acquired = False
|
|
||||||
display_name = progressive_names[item_name][level + 1]
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower()
|
|
||||||
display_data[base_name + "_acquired"] = acquired
|
|
||||||
display_data[base_name + "_url"] = alttp_icons[display_name]
|
|
||||||
|
|
||||||
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
|
|
||||||
sp_areas = ordered_areas[2:15]
|
|
||||||
|
|
||||||
player_big_key_locations = set()
|
|
||||||
player_small_key_locations = set()
|
|
||||||
for loc_data in locations.values():
|
|
||||||
for values in loc_data.values():
|
|
||||||
item_id, item_player, flags = values
|
|
||||||
if item_player == player:
|
|
||||||
if item_id in ids_big_key:
|
|
||||||
player_big_key_locations.add(ids_big_key[item_id])
|
|
||||||
elif item_id in ids_small_key:
|
|
||||||
player_small_key_locations.add(ids_small_key[item_id])
|
|
||||||
|
|
||||||
return render_template("lttpTracker.html", inventory=inventory,
|
|
||||||
player_name=player_name, room=room, icons=alttp_icons, checks_done=checks_done,
|
|
||||||
checks_in_area=seed_checks_in_area[player],
|
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
|
|
||||||
small_key_ids=small_key_ids, big_key_ids=big_key_ids, sp_areas=sp_areas,
|
|
||||||
key_locations=player_small_key_locations,
|
|
||||||
big_key_locations=player_big_key_locations,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
icons = {
|
|
||||||
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
|
|
||||||
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
|
|
||||||
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
|
|
||||||
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
|
|
||||||
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
|
|
||||||
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
|
|
||||||
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
|
|
||||||
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
|
|
||||||
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
|
|
||||||
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
|
|
||||||
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
|
|
||||||
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
|
|
||||||
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
|
|
||||||
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
|
|
||||||
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
|
|
||||||
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
|
|
||||||
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
|
|
||||||
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
|
|
||||||
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
|
|
||||||
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
|
|
||||||
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
|
|
||||||
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
|
|
||||||
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
|
|
||||||
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
|
|
||||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
|
||||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
minecraft_location_ids = {
|
|
||||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
|
||||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
|
||||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
|
||||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014],
|
|
||||||
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
|
|
||||||
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020,
|
|
||||||
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100],
|
|
||||||
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036,
|
|
||||||
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
|
|
||||||
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
|
|
||||||
}
|
|
||||||
|
|
||||||
display_data = {}
|
|
||||||
|
|
||||||
# Determine display for progressive items
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Tools": 45013,
|
|
||||||
"Progressive Weapons": 45012,
|
|
||||||
"Progressive Armor": 45014,
|
|
||||||
"Progressive Resource Crafting": 45001
|
|
||||||
}
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
|
|
||||||
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
|
|
||||||
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
|
|
||||||
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
|
|
||||||
}
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
|
||||||
display_data[base_name + "_url"] = icons[display_name]
|
|
||||||
|
|
||||||
# Multi-items
|
|
||||||
multi_items = {
|
|
||||||
"3 Ender Pearls": 45029,
|
|
||||||
"8 Netherite Scrap": 45015
|
|
||||||
}
|
|
||||||
for item_name, item_id in multi_items.items():
|
|
||||||
base_name = item_name.split()[-1].lower()
|
|
||||||
count = inventory[item_id]
|
|
||||||
if count >= 0:
|
|
||||||
display_data[base_name + "_count"] = count
|
|
||||||
|
|
||||||
# Victory condition
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
display_data['game_finished'] = game_state == 30
|
|
||||||
|
|
||||||
# Turn location IDs into advancement tab counts
|
|
||||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
|
||||||
lookup_name = lambda id: lookup_any_location_id_to_name[id]
|
|
||||||
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
|
|
||||||
for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_done['Total'] = len(checked_locations)
|
|
||||||
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
|
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
|
||||||
|
|
||||||
return render_template("minecraftTracker.html",
|
|
||||||
inventory=inventory, icons=icons,
|
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
|
||||||
id in lookup_any_item_id_to_name},
|
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
|
||||||
|
|
||||||
icons = {
|
|
||||||
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
|
|
||||||
"Ocarina of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Ocarina_of_Time_Icon.png",
|
|
||||||
"Slingshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/32/OoT_Fairy_Slingshot_Icon.png",
|
|
||||||
"Boomerang": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/d5/OoT_Boomerang_Icon.png",
|
|
||||||
"Bottle": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/fc/OoT_Bottle_Icon.png",
|
|
||||||
"Rutos Letter": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/OoT_Letter_Icon.png",
|
|
||||||
"Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/11/OoT_Bomb_Icon.png",
|
|
||||||
"Bombchus": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/36/OoT_Bombchu_Icon.png",
|
|
||||||
"Lens of Truth": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/05/OoT_Lens_of_Truth_Icon.png",
|
|
||||||
"Bow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9a/OoT_Fairy_Bow_Icon.png",
|
|
||||||
"Hookshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/77/OoT_Hookshot_Icon.png",
|
|
||||||
"Longshot": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/OoT_Longshot_Icon.png",
|
|
||||||
"Megaton Hammer": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/93/OoT_Megaton_Hammer_Icon.png",
|
|
||||||
"Fire Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1e/OoT_Fire_Arrow_Icon.png",
|
|
||||||
"Ice Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3c/OoT_Ice_Arrow_Icon.png",
|
|
||||||
"Light Arrows": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/76/OoT_Light_Arrow_Icon.png",
|
|
||||||
"Dins Fire": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/d/da/OoT_Din%27s_Fire_Icon.png",
|
|
||||||
"Farores Wind": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/7/7a/OoT_Farore%27s_Wind_Icon.png",
|
|
||||||
"Nayrus Love": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/be/OoT_Nayru%27s_Love_Icon.png",
|
|
||||||
"Kokiri Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/5/53/OoT_Kokiri_Sword_Icon.png",
|
|
||||||
"Biggoron Sword": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2e/OoT_Giant%27s_Knife_Icon.png",
|
|
||||||
"Mirror Shield": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b0/OoT_Mirror_Shield_Icon_2.png",
|
|
||||||
"Goron Bracelet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b7/OoT_Goron%27s_Bracelet_Icon.png",
|
|
||||||
"Silver Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/b/b9/OoT_Silver_Gauntlets_Icon.png",
|
|
||||||
"Golden Gauntlets": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/6a/OoT_Golden_Gauntlets_Icon.png",
|
|
||||||
"Goron Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/1/1c/OoT_Goron_Tunic_Icon.png",
|
|
||||||
"Zora Tunic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/2c/OoT_Zora_Tunic_Icon.png",
|
|
||||||
"Silver Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Silver_Scale_Icon.png",
|
|
||||||
"Gold Scale": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/95/OoT_Golden_Scale_Icon.png",
|
|
||||||
"Iron Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/34/OoT_Iron_Boots_Icon.png",
|
|
||||||
"Hover Boots": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/22/OoT_Hover_Boots_Icon.png",
|
|
||||||
"Adults Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f9/OoT_Adult%27s_Wallet_Icon.png",
|
|
||||||
"Giants Wallet": r"https://static.wikia.nocookie.net/zelda_gamepedia_en/images/8/87/OoT_Giant%27s_Wallet_Icon.png",
|
|
||||||
"Small Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/9f/OoT3D_Magic_Jar_Icon.png",
|
|
||||||
"Large Magic": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/3e/OoT3D_Large_Magic_Jar_Icon.png",
|
|
||||||
"Gerudo Membership Card": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/4e/OoT_Gerudo_Token_Icon.png",
|
|
||||||
"Gold Skulltula Token": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/47/OoT_Token_Icon.png",
|
|
||||||
"Triforce Piece": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0b/SS_Triforce_Piece_Icon.png",
|
|
||||||
"Triforce": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/6/68/ALttP_Triforce_Title_Sprite.png",
|
|
||||||
"Zeldas Lullaby": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Eponas Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Sarias Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Suns Song": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Song of Time": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Song of Storms": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/2/21/Grey_Note.png",
|
|
||||||
"Minuet of Forest": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e4/Green_Note.png",
|
|
||||||
"Bolero of Fire": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/f/f0/Red_Note.png",
|
|
||||||
"Serenade of Water": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/0/0f/Blue_Note.png",
|
|
||||||
"Requiem of Spirit": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/a/a4/Orange_Note.png",
|
|
||||||
"Nocturne of Shadow": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/Purple_Note.png",
|
|
||||||
"Prelude of Light": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/90/Yellow_Note.png",
|
|
||||||
"Small Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/e/e5/OoT_Small_Key_Icon.png",
|
|
||||||
"Boss Key": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/4/40/OoT_Boss_Key_Icon.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
display_data = {}
|
|
||||||
|
|
||||||
# Determine display for progressive items
|
|
||||||
progressive_items = {
|
|
||||||
"Progressive Hookshot": 66128,
|
|
||||||
"Progressive Strength Upgrade": 66129,
|
|
||||||
"Progressive Wallet": 66133,
|
|
||||||
"Progressive Scale": 66134,
|
|
||||||
"Magic Meter": 66138,
|
|
||||||
"Ocarina": 66139,
|
|
||||||
}
|
|
||||||
|
|
||||||
progressive_names = {
|
|
||||||
"Progressive Hookshot": ["Hookshot", "Hookshot", "Longshot"],
|
|
||||||
"Progressive Strength Upgrade": ["Goron Bracelet", "Goron Bracelet", "Silver Gauntlets", "Golden Gauntlets"],
|
|
||||||
"Progressive Wallet": ["Adults Wallet", "Adults Wallet", "Giants Wallet", "Giants Wallet"],
|
|
||||||
"Progressive Scale": ["Silver Scale", "Silver Scale", "Gold Scale"],
|
|
||||||
"Magic Meter": ["Small Magic", "Small Magic", "Large Magic"],
|
|
||||||
"Ocarina": ["Fairy Ocarina", "Fairy Ocarina", "Ocarina of Time"]
|
|
||||||
}
|
|
||||||
|
|
||||||
for item_name, item_id in progressive_items.items():
|
|
||||||
level = min(inventory[item_id], len(progressive_names[item_name])-1)
|
|
||||||
display_name = progressive_names[item_name][level]
|
|
||||||
if item_name.startswith("Progressive"):
|
|
||||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
|
||||||
else:
|
|
||||||
base_name = item_name.lower().replace(' ', '_')
|
|
||||||
display_data[base_name+"_url"] = icons[display_name]
|
|
||||||
|
|
||||||
if base_name == "hookshot":
|
|
||||||
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
|
|
||||||
if base_name == "wallet":
|
|
||||||
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
|
|
||||||
|
|
||||||
# Determine display for bottles. Show letter if it's obtained, determine bottle count
|
|
||||||
bottle_ids = [66015, 66020, 66021, 66140, 66141, 66142, 66143, 66144, 66145, 66146, 66147, 66148]
|
|
||||||
display_data['bottle_count'] = min(sum(map(lambda item_id: inventory[item_id], bottle_ids)), 4)
|
|
||||||
display_data['bottle_url'] = icons['Rutos Letter'] if inventory[66021] > 0 else icons['Bottle']
|
|
||||||
|
|
||||||
# Determine bombchu display
|
|
||||||
display_data['has_bombchus'] = any(map(lambda item_id: inventory[item_id] > 0, [66003, 66106, 66107, 66137]))
|
|
||||||
|
|
||||||
# Multi-items
|
|
||||||
multi_items = {
|
|
||||||
"Gold Skulltula Token": 66091,
|
|
||||||
"Triforce Piece": 66202,
|
|
||||||
}
|
|
||||||
for item_name, item_id in multi_items.items():
|
|
||||||
base_name = item_name.split()[-1].lower()
|
|
||||||
count = inventory[item_id]
|
|
||||||
display_data[base_name+"_count"] = inventory[item_id]
|
|
||||||
|
|
||||||
# Gather dungeon locations
|
|
||||||
area_id_ranges = {
|
|
||||||
"Overworld": (67000, 67280),
|
|
||||||
"Deku Tree": (67281, 67303),
|
|
||||||
"Dodongo's Cavern": (67304, 67334),
|
|
||||||
"Jabu Jabu's Belly": (67335, 67359),
|
|
||||||
"Bottom of the Well": (67360, 67384),
|
|
||||||
"Forest Temple": (67385, 67420),
|
|
||||||
"Fire Temple": (67421, 67457),
|
|
||||||
"Water Temple": (67458, 67484),
|
|
||||||
"Shadow Temple": (67485, 67532),
|
|
||||||
"Spirit Temple": (67533, 67582),
|
|
||||||
"Ice Cavern": (67583, 67596),
|
|
||||||
"Gerudo Training Ground": (67597, 67635),
|
|
||||||
"Thieves' Hideout": (67259, 67263),
|
|
||||||
"Ganon's Castle": (67636, 67673),
|
|
||||||
}
|
|
||||||
|
|
||||||
def lookup_and_trim(id, area):
|
|
||||||
full_name = lookup_any_location_id_to_name[id]
|
|
||||||
if id == 67673:
|
|
||||||
return full_name[13:] # Ganons Tower Boss Key Chest
|
|
||||||
if area not in ["Overworld", "Thieves' Hideout"]:
|
|
||||||
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
|
|
||||||
return full_name[len(area):]
|
|
||||||
return full_name
|
|
||||||
|
|
||||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
|
|
||||||
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
|
|
||||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
|
||||||
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
|
|
||||||
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
|
|
||||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
|
||||||
|
|
||||||
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
|
|
||||||
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
|
|
||||||
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
|
|
||||||
for loc in location_info["Thieves' Hideout"]:
|
|
||||||
del location_info["Overworld"][loc]
|
|
||||||
|
|
||||||
checks_done['Total'] = sum(checks_done.values())
|
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
|
||||||
|
|
||||||
# Give skulltulas on non-tracked locations
|
|
||||||
non_tracked_locations = multisave.get("location_checks", {}).get((team, player), set()).difference(set(locations[player]))
|
|
||||||
for id in non_tracked_locations:
|
|
||||||
if "GS" in lookup_and_trim(id, ''):
|
|
||||||
display_data["token_count"] += 1
|
|
||||||
|
|
||||||
# Gather small and boss key info
|
|
||||||
small_key_counts = {
|
|
||||||
"Forest Temple": inventory[66175],
|
|
||||||
"Fire Temple": inventory[66176],
|
|
||||||
"Water Temple": inventory[66177],
|
|
||||||
"Spirit Temple": inventory[66178],
|
|
||||||
"Shadow Temple": inventory[66179],
|
|
||||||
"Bottom of the Well": inventory[66180],
|
|
||||||
"Gerudo Training Ground": inventory[66181],
|
|
||||||
"Thieves' Hideout": inventory[66182],
|
|
||||||
"Ganon's Castle": inventory[66183],
|
|
||||||
}
|
|
||||||
boss_key_counts = {
|
|
||||||
"Forest Temple": '✔' if inventory[66149] else '✕',
|
|
||||||
"Fire Temple": '✔' if inventory[66150] else '✕',
|
|
||||||
"Water Temple": '✔' if inventory[66151] else '✕',
|
|
||||||
"Spirit Temple": '✔' if inventory[66152] else '✕',
|
|
||||||
"Shadow Temple": '✔' if inventory[66153] else '✕',
|
|
||||||
"Ganon's Castle": '✔' if inventory[66154] else '✕',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Victory condition
|
|
||||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
|
||||||
display_data['game_finished'] = game_state == 30
|
|
||||||
|
|
||||||
return render_template("ootTracker.html",
|
|
||||||
inventory=inventory, player=player, team=team, room=room, player_name=playerName,
|
|
||||||
icons=icons, acquired_items={lookup_any_item_id_to_name[id] for id in inventory},
|
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
|
||||||
small_key_counts=small_key_counts, boss_key_counts=boss_key_counts,
|
|
||||||
**display_data)
|
|
||||||
|
|
||||||
|
|
||||||
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
||||||
|
|
||||||
@@ -745,7 +524,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
"Present": [
|
"Present": [
|
||||||
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
||||||
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
||||||
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
||||||
@@ -766,20 +545,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
||||||
1337171, 1337172, 1337173, 1337174, 1337175],
|
1337171, 1337172, 1337173, 1337174, 1337175],
|
||||||
"Ancient Pyramid": [
|
"Ancient Pyramid": [
|
||||||
1337236,
|
1337236,
|
||||||
1337246, 1337247, 1337248, 1337249]
|
1337246, 1337247, 1337248, 1337249]
|
||||||
}
|
}
|
||||||
|
|
||||||
if(slot_data["DownloadableItems"]):
|
if(slot_data["DownloadableItems"]):
|
||||||
timespinner_location_ids["Present"] += [
|
timespinner_location_ids["Present"] += [
|
||||||
1337156, 1337157, 1337159,
|
1337156, 1337157, 1337159,
|
||||||
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||||
1337170]
|
1337170]
|
||||||
if(slot_data["Cantoran"]):
|
if(slot_data["Cantoran"]):
|
||||||
timespinner_location_ids["Past"].append(1337176)
|
timespinner_location_ids["Past"].append(1337176)
|
||||||
if(slot_data["LoreChecks"]):
|
if(slot_data["LoreChecks"]):
|
||||||
timespinner_location_ids["Present"] += [
|
timespinner_location_ids["Present"] += [
|
||||||
1337177, 1337178, 1337179,
|
1337177, 1337178, 1337179,
|
||||||
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
||||||
timespinner_location_ids["Past"] += [
|
timespinner_location_ids["Past"] += [
|
||||||
1337188, 1337189,
|
1337188, 1337189,
|
||||||
@@ -808,13 +587,13 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
|||||||
acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name}
|
acquired_items = {lookup_any_item_id_to_name[id] for id in inventory if id in lookup_any_item_id_to_name}
|
||||||
options = {k for k, v in slot_data.items() if v}
|
options = {k for k, v in slot_data.items() if v}
|
||||||
|
|
||||||
return render_template("timespinnerTracker.html",
|
return render_template("trackers/" + "timespinnerTracker.html",
|
||||||
inventory=inventory, icons=icons, acquired_items=acquired_items,
|
inventory=inventory, icons=icons, acquired_items=acquired_items,
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
player=player, team=team, room=room, player_name=playerName,
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||||
options=options, **display_data)
|
options=options, **display_data)
|
||||||
|
|
||||||
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||||
|
|
||||||
@@ -889,6 +668,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
|
|
||||||
for item_name, item_id in multi_items.items():
|
for item_name, item_id in multi_items.items():
|
||||||
base_name = item_name.split()[0].lower()
|
base_name = item_name.split()[0].lower()
|
||||||
|
count = inventory[item_id]
|
||||||
display_data[base_name+"_count"] = inventory[item_id]
|
display_data[base_name+"_count"] = inventory[item_id]
|
||||||
|
|
||||||
# Victory condition
|
# Victory condition
|
||||||
@@ -906,7 +686,7 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()}
|
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in supermetroid_location_ids.items()}
|
||||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||||
|
|
||||||
return render_template("supermetroidTracker.html",
|
return render_template("trackers/" + "supermetroidTracker.html",
|
||||||
inventory=inventory, icons=icons,
|
inventory=inventory, icons=icons,
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||||
id in lookup_any_item_id_to_name},
|
id in lookup_any_item_id_to_name},
|
||||||
@@ -914,7 +694,8 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
|||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||||
**display_data)
|
**display_data)
|
||||||
|
|
||||||
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
|
||||||
|
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: set,
|
||||||
inventory: Counter, team: int, player: int, playerName: str,
|
inventory: Counter, team: int, player: int, playerName: str,
|
||||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
||||||
|
|
||||||
@@ -929,11 +710,11 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
|
|||||||
for order_index, networkItem in enumerate(ordered_items, start=1):
|
for order_index, networkItem in enumerate(ordered_items, start=1):
|
||||||
player_received_items[networkItem.item] = order_index
|
player_received_items[networkItem.item] = order_index
|
||||||
|
|
||||||
return render_template("genericTracker.html",
|
return render_template("trackers/" + "genericTracker.html",
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
player=player, team=team, room=room, player_name=playerName,
|
player=player, team=team, room=room, player_name=playerName,
|
||||||
checked_locations=checked_locations,
|
checked_locations=checked_locations,
|
||||||
not_checked_locations=set(locations[player]) - checked_locations,
|
not_checked_locations=locations - checked_locations,
|
||||||
received_items=player_received_items)
|
received_items=player_received_items)
|
||||||
|
|
||||||
|
|
||||||
@@ -975,9 +756,9 @@ def getTracker(tracker: UUID):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
item, recipient, flags = player_locations[location]
|
item, recipient, flags = player_locations[location]
|
||||||
|
|
||||||
if recipient in names:
|
if recipient in names:
|
||||||
attribute_item(inventory, team, recipient, item)
|
attribute_item(inventory, team, recipient, item)
|
||||||
|
|
||||||
checks_done[team][player][player_location_to_area[player][location]] += 1
|
checks_done[team][player][player_location_to_area[player][location]] += 1
|
||||||
checks_done[team][player]["Total"] += 1
|
checks_done[team][player]["Total"] += 1
|
||||||
|
|
||||||
@@ -987,10 +768,10 @@ def getTracker(tracker: UUID):
|
|||||||
if game_state == 30:
|
if game_state == 30:
|
||||||
inventory[team][player][106] = 1 # Triforce
|
inventory[team][player][106] = 1 # Triforce
|
||||||
|
|
||||||
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
|
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
|
||||||
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
|
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
|
||||||
for loc_data in locations.values():
|
for loc_data in locations.values():
|
||||||
for values in loc_data.values():
|
for values in loc_data.values():
|
||||||
item_id, item_player, flags = values
|
item_id, item_player, flags = values
|
||||||
|
|
||||||
if item_id in ids_big_key:
|
if item_id in ids_big_key:
|
||||||
@@ -1021,7 +802,7 @@ def getTracker(tracker: UUID):
|
|||||||
for (team, player), data in multisave.get("video", []):
|
for (team, player), data in multisave.get("video", []):
|
||||||
video[(team, player)] = data
|
video[(team, player)] = data
|
||||||
|
|
||||||
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
|
return render_template("trackers/" + "multiworldTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
|
||||||
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
||||||
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
|
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
|
||||||
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
||||||
@@ -1032,9 +813,6 @@ def getTracker(tracker: UUID):
|
|||||||
|
|
||||||
|
|
||||||
game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||||
"Minecraft": __renderMinecraftTracker,
|
|
||||||
"Ocarina of Time": __renderOoTTracker,
|
|
||||||
"Timespinner": __renderTimespinnerTracker,
|
"Timespinner": __renderTimespinnerTracker,
|
||||||
"A Link to the Past": __renderAlttpTracker,
|
|
||||||
"Super Metroid": __renderSuperMetroidTracker
|
"Super Metroid": __renderSuperMetroidTracker
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||||
player_id=int(slot_id[1:]), game="Ocarina of Time"))
|
player_id=int(slot_id[1:]), game="Ocarina of Time"))
|
||||||
|
|
||||||
|
elif file.filename.endswith(".json"):
|
||||||
|
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
|
||||||
|
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||||
|
player_id=int(slot_id[1:]), game="Dark Souls III"))
|
||||||
|
|
||||||
elif file.filename.endswith(".txt"):
|
elif file.filename.endswith(".txt"):
|
||||||
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
|
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ local extensionConsumableLookup = {
|
|||||||
[443] = 0x3F
|
[443] = 0x3F
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local noOverworldItemsLookup = {
|
||||||
|
[499] = 0x2B,
|
||||||
|
[500] = 0x12,
|
||||||
|
}
|
||||||
|
|
||||||
local itemMessages = {}
|
local itemMessages = {}
|
||||||
local consumableStacks = nil
|
local consumableStacks = nil
|
||||||
local prevstate = ""
|
local prevstate = ""
|
||||||
@@ -341,7 +346,7 @@ function processBlock(block)
|
|||||||
-- This is a key item
|
-- This is a key item
|
||||||
memoryLocation = memoryLocation - 0x0E0
|
memoryLocation = memoryLocation - 0x0E0
|
||||||
wU8(memoryLocation, 0x01)
|
wU8(memoryLocation, 0x01)
|
||||||
elseif v >= 0x1E0 then
|
elseif v >= 0x1E0 and v <= 0x1F2 then
|
||||||
-- This is a movement item
|
-- This is a movement item
|
||||||
-- Minus Offset (0x100) - movement offset (0xE0)
|
-- Minus Offset (0x100) - movement offset (0xE0)
|
||||||
memoryLocation = memoryLocation - 0x1E0
|
memoryLocation = memoryLocation - 0x1E0
|
||||||
@@ -351,7 +356,10 @@ function processBlock(block)
|
|||||||
else
|
else
|
||||||
wU8(memoryLocation, 0x01)
|
wU8(memoryLocation, 0x01)
|
||||||
end
|
end
|
||||||
|
elseif v >= 0x1F3 and v <= 0x1F4 then
|
||||||
|
-- NoOverworld special items
|
||||||
|
memoryLocation = noOverworldItemsLookup[v]
|
||||||
|
wU8(memoryLocation, 0x01)
|
||||||
elseif v >= 0x16C and v <= 0x1AF then
|
elseif v >= 0x16C and v <= 0x1AF then
|
||||||
-- This is a gold item
|
-- This is a gold item
|
||||||
amountToAdd = goldLookup[v]
|
amountToAdd = goldLookup[v]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ local socket = require("socket")
|
|||||||
local json = require('json')
|
local json = require('json')
|
||||||
local math = require('math')
|
local math = require('math')
|
||||||
|
|
||||||
local last_modified_date = '2022-05-25' -- Should be the last modified date
|
local last_modified_date = '2022-07-24' -- Should be the last modified date
|
||||||
local script_version = 1
|
local script_version = 2
|
||||||
|
|
||||||
--------------------------------------------------
|
--------------------------------------------------
|
||||||
-- Heavily modified form of RiptideSage's tracker
|
-- Heavily modified form of RiptideSage's tracker
|
||||||
@@ -1723,6 +1723,11 @@ function get_death_state()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function kill_link()
|
function kill_link()
|
||||||
|
-- market entrance: 27/28/29
|
||||||
|
-- outside ToT: 35/36/37.
|
||||||
|
-- if killed on these scenes the game crashes, so we wait until not on this screen.
|
||||||
|
local scene = global_context:rawget('cur_scene'):rawget()
|
||||||
|
if scene == 27 or scene == 28 or scene == 29 or scene == 35 or scene == 36 or scene == 37 then return end
|
||||||
mainmemory.write_u16_be(0x11A600, 0)
|
mainmemory.write_u16_be(0x11A600, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1824,13 +1829,15 @@ function main()
|
|||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
if (frame % 60 == 0) then
|
if (frame % 60 == 0) then
|
||||||
server:settimeout(2)
|
server:settimeout(2)
|
||||||
print("Attempting to connect")
|
|
||||||
local client, timeout = server:accept()
|
local client, timeout = server:accept()
|
||||||
if timeout == nil then
|
if timeout == nil then
|
||||||
print('Initial Connection Made')
|
print('Initial Connection Made')
|
||||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||||
ootSocket = client
|
ootSocket = client
|
||||||
ootSocket:settimeout(0)
|
ootSocket:settimeout(0)
|
||||||
|
else
|
||||||
|
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
|
||||||
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
32
docs/apworld specification.md
Normal file
32
docs/apworld specification.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# apworld Specification
|
||||||
|
|
||||||
|
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||||
|
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
|
||||||
|
See [world api.md](world api.md) for details.
|
||||||
|
|
||||||
|
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||||
|
file into the worlds folder.
|
||||||
|
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
apworld files are zip archives with the case-sensitive file ending `.apworld`.
|
||||||
|
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
||||||
|
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
||||||
|
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
No metadata is specified yet.
|
||||||
|
|
||||||
|
|
||||||
|
## Extra Data
|
||||||
|
|
||||||
|
The zip can contain arbitrary files in addition what was specified above.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Imports from other files inside the apworld have to use relative imports.
|
||||||
|
|
||||||
|
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.
|
||||||
11
docs/code_of_conduct.md
Normal file
11
docs/code_of_conduct.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||||
|
|
||||||
|
* Be welcoming and inclusive in tone and language.
|
||||||
|
* Be respectful of others and their abilities.
|
||||||
|
* Show empathy when speaking with others.
|
||||||
|
* Be gracious and accept feedback and constructive criticism.
|
||||||
|
|
||||||
|
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
|
||||||
|
|
||||||
|
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.
|
||||||
12
docs/contributing.md
Normal file
12
docs/contributing.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Contributing
|
||||||
|
Contributions are welcome. We have a few requests of any new contributors.
|
||||||
|
|
||||||
|
* Ensure that all changes which affect logic are covered by unit tests.
|
||||||
|
* Do not introduce any unit test failures/regressions.
|
||||||
|
* Follow styling as designated in our [styling documentation](/docs/style.md).
|
||||||
|
|
||||||
|
Otherwise, we tend to judge code on a case to case basis.
|
||||||
|
|
||||||
|
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
|
||||||
|
[the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
|
||||||
|
channel in our [Discord](https://archipelago.gg/discord).
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 374 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 82 KiB |
BIN
docs/network diagram/network diagram.jpg
Normal file
BIN
docs/network diagram/network diagram.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 526 KiB |
@@ -8,6 +8,15 @@ flowchart LR
|
|||||||
CC[CommonClient.py]
|
CC[CommonClient.py]
|
||||||
AS <-- WebSockets --> CC
|
AS <-- WebSockets --> CC
|
||||||
|
|
||||||
|
subgraph "Starcraft 2"
|
||||||
|
SC2[Starcraft 2 Game Client]
|
||||||
|
SC2C[Starcraft2Client.py]
|
||||||
|
SC2AI[apsc2 Python Package]
|
||||||
|
|
||||||
|
SC2C <--> SC2AI <-- WebSockets --> SC2
|
||||||
|
end
|
||||||
|
CC <-- Integrated --> SC2C
|
||||||
|
|
||||||
%% ChecksFinder
|
%% ChecksFinder
|
||||||
subgraph ChecksFinder
|
subgraph ChecksFinder
|
||||||
CFC[ChecksFinderClient]
|
CFC[ChecksFinderClient]
|
||||||
@@ -60,6 +69,12 @@ flowchart LR
|
|||||||
end
|
end
|
||||||
SNI <-- Various, depending on SNES device --> SMZ
|
SNI <-- Various, depending on SNES device --> SMZ
|
||||||
|
|
||||||
|
%% Donkey Kong Country 3
|
||||||
|
subgraph Donkey Kong Country 3
|
||||||
|
DK3[SNES]
|
||||||
|
end
|
||||||
|
SNI <-- Various, depending on SNES device --> DK3
|
||||||
|
|
||||||
%% Native Clients or Games
|
%% Native Clients or Games
|
||||||
%% Games or clients which compile to native or which the client is integrated in the game.
|
%% Games or clients which compile to native or which the client is integrated in the game.
|
||||||
subgraph "Native"
|
subgraph "Native"
|
||||||
@@ -72,12 +87,16 @@ flowchart LR
|
|||||||
V6[VVVVVV]
|
V6[VVVVVV]
|
||||||
MT[Meritous]
|
MT[Meritous]
|
||||||
TW[The Witness]
|
TW[The Witness]
|
||||||
|
SA2B[Sonic Adventure 2: Battle]
|
||||||
|
DS3[Dark Souls 3]
|
||||||
|
|
||||||
APCLIENTPP <--> SOE
|
APCLIENTPP <--> SOE
|
||||||
APCLIENTPP <--> MT
|
APCLIENTPP <--> MT
|
||||||
APCLIENTPP <-- The Witness Randomizer --> TW
|
APCLIENTPP <-- The Witness Randomizer --> TW
|
||||||
|
APCLIENTPP <--> DS3
|
||||||
APCPP <--> SM64
|
APCPP <--> SM64
|
||||||
APCPP <--> V6
|
APCPP <--> V6
|
||||||
|
APCPP <--> SA2B
|
||||||
end
|
end
|
||||||
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
|
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
|
||||||
AS <-- WebSockets --> APCLIENTPP
|
AS <-- WebSockets --> APCLIENTPP
|
||||||
1
docs/network diagram/network diagram.svg
Normal file
1
docs/network diagram/network diagram.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 92 KiB |
@@ -13,9 +13,18 @@ These steps should be followed in order to establish a gameplay connection with
|
|||||||
|
|
||||||
In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet.
|
In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet.
|
||||||
|
|
||||||
There are libraries available that implement this network protocol in [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py), [Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java), [.Net](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Net) and [C++](https://github.com/black-sliver/apclientpp)
|
There are also a number of community-supported libraries available that implement this network protocol to make integrating with Archipelago easier.
|
||||||
|
|
||||||
For Super Nintendo games there are clients available in either [Node](https://github.com/ArchipelagoMW/SuperNintendoClient) or [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py), There are also game specific clients available for [The Legend of Zelda: Ocarina of Time](https://github.com/ArchipelagoMW/Z5Client) or [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/FF1Client.py)
|
| Language/Runtime | Project | Remarks |
|
||||||
|
|-------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||||
|
| Python | [Archipelago CommonClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py) | |
|
||||||
|
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
|
||||||
|
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
|
||||||
|
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
|
||||||
|
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
|
||||||
|
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
|
||||||
|
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
|
||||||
|
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
|
||||||
|
|
||||||
## Synchronizing Items
|
## Synchronizing Items
|
||||||
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
|
||||||
@@ -152,7 +161,8 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
|
|||||||
All arguments for this packet are optional, only changes are sent.
|
All arguments for this packet are optional, only changes are sent.
|
||||||
|
|
||||||
### Print
|
### Print
|
||||||
Sent to clients purely to display a message to the player.
|
Sent to clients purely to display a message to the player.
|
||||||
|
* *Deprecation warning: clients that connect with version 0.3.5 or higher will nolonger recieve Print packets, instead all messsages are send as [PrintJSON](#PrintJSON)*
|
||||||
#### Arguments
|
#### Arguments
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
| ---- | ---- | ----- |
|
| ---- | ---- | ----- |
|
||||||
@@ -164,10 +174,21 @@ Sent to clients purely to display a message to the player. This packet differs f
|
|||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
| ---- | ---- | ----- |
|
| ---- | ---- | ----- |
|
||||||
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. |
|
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. |
|
||||||
| type | str | May be present to indicate the nature of this message. Known types are Hint and ItemSend. |
|
| type | str | May be present to indicate the [PrintJsonType](#PrintJsonType) of this message. |
|
||||||
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
|
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
|
||||||
| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. |
|
| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. |
|
||||||
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
|
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
|
||||||
|
| countdown | int | Is present if type is `Countdown`, denotes the amount of seconds remaining on the countdown. |
|
||||||
|
|
||||||
|
##### PrintJsonType
|
||||||
|
PrintJsonType indicates the type of [PrintJson](#PrintJson) packet, different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown type the data's list\[[JSONMessagePart](#JSONMessagePart)\] should still be printed as normal.
|
||||||
|
|
||||||
|
Currently defined types are:
|
||||||
|
| Type | Notes |
|
||||||
|
| ---- | ----- |
|
||||||
|
| ItemSend | The message is in response to a player receiving an item. |
|
||||||
|
| Hint | The message is in response to a player hinting. |
|
||||||
|
| Countdown | The message contains information about the current server Countdown. |
|
||||||
|
|
||||||
### DataPackage
|
### DataPackage
|
||||||
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
|
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
|
||||||
@@ -191,8 +212,23 @@ Sent to clients after a client requested this message be sent to them, more info
|
|||||||
### InvalidPacket
|
### InvalidPacket
|
||||||
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
||||||
|
|
||||||
|
#### Arguments
|
||||||
|
| Name | Type | Notes |
|
||||||
|
| ---- | ---- | ----- |
|
||||||
|
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
|
||||||
|
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
|
||||||
|
| text | str | A descriptive message of the problem at hand. |
|
||||||
|
|
||||||
|
##### PacketProblemType
|
||||||
|
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
|
||||||
|
|
||||||
|
| Type | Notes |
|
||||||
|
| ---- | ----- |
|
||||||
|
| cmd | `cmd` argument of the faulty packet that could not be parsed correctly. |
|
||||||
|
| arguments | Arguments of the faulty packet which were not correct. |
|
||||||
|
|
||||||
### Retrieved
|
### Retrieved
|
||||||
Sent to clients as a response the a [Get](#Get) package
|
Sent to clients as a response the a [Get](#Get) package.
|
||||||
#### Arguments
|
#### Arguments
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
| ---- | ---- | ----- |
|
| ---- | ---- | ----- |
|
||||||
@@ -486,7 +522,7 @@ Color options:
|
|||||||
* green_bg
|
* green_bg
|
||||||
* yellow_bg
|
* yellow_bg
|
||||||
* blue_bg
|
* blue_bg
|
||||||
* purple_bg
|
* magenta_bg
|
||||||
* cyan_bg
|
* cyan_bg
|
||||||
* white_bg
|
* white_bg
|
||||||
|
|
||||||
|
|||||||
63
docs/running from source.md
Normal file
63
docs/running from source.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Running From Source
|
||||||
|
|
||||||
|
If you just want to play and there is a compiled version available on the
|
||||||
|
[Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases),
|
||||||
|
use that version. These steps are for developers or platforms without compiled releases available.
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
What you'll need:
|
||||||
|
* Python 3.8.7 or newer
|
||||||
|
* pip (Depending on platform may come included)
|
||||||
|
* A C compiler
|
||||||
|
* possibly optional, read OS-specific sections
|
||||||
|
|
||||||
|
Then run any of the starting point scripts, like Generate.py, and the included ModuleUpdater should prompt to install or update the
|
||||||
|
required modules and after pressing enter proceed to install everything automatically.
|
||||||
|
After this, you should be able to run the programs.
|
||||||
|
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
Recommended steps
|
||||||
|
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
|
||||||
|
* Download and install full Visual Studio from
|
||||||
|
[Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/)
|
||||||
|
or an older "Build Tools for Visual Studio" from
|
||||||
|
[Visual Studio Older Downloads](https://visualstudio.microsoft.com/vs/older-downloads/).
|
||||||
|
|
||||||
|
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details
|
||||||
|
* This step is optional. Pre-compiled modules are pinned on
|
||||||
|
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
|
||||||
|
|
||||||
|
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
|
||||||
|
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
|
||||||
|
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
Refer to [Guide to Run Archipelago from Source Code on macOS](../worlds/generic/docs/mac_en.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Optional: A Link to the Past Enemizer
|
||||||
|
|
||||||
|
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||||
|
error if it is required.
|
||||||
|
|
||||||
|
You can get the latest Enemizer release at [Enemizer Github releases](https://github.com/Ijwu/Enemizer/releases).
|
||||||
|
It should be dropped as "EnemizerCLI" into the root folder of the project. Alternatively, you can point the Enemizer
|
||||||
|
setting in host.yaml at your Enemizer executable.
|
||||||
|
|
||||||
|
|
||||||
|
## Optional: SNI
|
||||||
|
|
||||||
|
SNI is required to use SNIClient. If not integrated into the project, it has to be started manually.
|
||||||
|
|
||||||
|
You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases).
|
||||||
|
It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in
|
||||||
|
host.yaml at your SNI folder.
|
||||||
|
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
||||||
49
docs/style.md
Normal file
49
docs/style.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Style Guide
|
||||||
|
|
||||||
|
## Generic
|
||||||
|
|
||||||
|
* This guide can be ignored for data files that are not to be viewed in an editor.
|
||||||
|
* 120 character per line for all source files.
|
||||||
|
* Avoid white space errors like trailing spaces.
|
||||||
|
|
||||||
|
|
||||||
|
## Python Code
|
||||||
|
|
||||||
|
* We mostly follow [PEP8](https://peps.python.org/pep-0008/). Read below to see the differences.
|
||||||
|
* 120 characters per line. PyCharm does this automatically, other editors can be configured for it.
|
||||||
|
* Strings in core code will be `"strings"`. In other words: double quote your strings.
|
||||||
|
* Strings in worlds should use double quotes as well, but imported code may differ.
|
||||||
|
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
||||||
|
use single quotes inside them: `f"Like {dct['key']}"`
|
||||||
|
* Use type annotation where possible.
|
||||||
|
|
||||||
|
|
||||||
|
## Markdown
|
||||||
|
|
||||||
|
* We almost follow [Google's styleguide](https://google.github.io/styleguide/docguide/style.html).
|
||||||
|
Read below for differences.
|
||||||
|
* For existing documents, try to follow its style or ask to completely reformat it.
|
||||||
|
* 120 characters per line.
|
||||||
|
* One space between bullet/number and text.
|
||||||
|
* No lazy numbering.
|
||||||
|
|
||||||
|
|
||||||
|
## HTML
|
||||||
|
|
||||||
|
* Indent with 2 spaces for new code.
|
||||||
|
* kebab-case for ids and classes.
|
||||||
|
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
* Indent with 2 spaces for new code.
|
||||||
|
* `{` on the same line as the selector.
|
||||||
|
* No space between selector and `{`.
|
||||||
|
|
||||||
|
|
||||||
|
## JS
|
||||||
|
|
||||||
|
* Indent with 2 spaces.
|
||||||
|
* Indent `case` inside `switch ` with 2 spaces.
|
||||||
|
* Use single quotes.
|
||||||
|
* Semicolons are required after every statement.
|
||||||
@@ -86,7 +86,7 @@ inside a World object.
|
|||||||
|
|
||||||
Players provide customized settings for their World in the form of yamls.
|
Players provide customized settings for their World in the form of yamls.
|
||||||
Those are accessible through `self.world.<option_name>[self.player]`. A dict
|
Those are accessible through `self.world.<option_name>[self.player]`. A dict
|
||||||
of valid options has to be provided in `self.options`. Options are automatically
|
of valid options has to be provided in `self.option_definitions`. Options are automatically
|
||||||
added to the `World` object for easy access.
|
added to the `World` object for easy access.
|
||||||
|
|
||||||
### World Options
|
### World Options
|
||||||
@@ -103,8 +103,9 @@ or boss drops for RPG-like games but could also be progress in a research tree.
|
|||||||
|
|
||||||
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
|
||||||
in a Region and has access rules.
|
in a Region and has access rules.
|
||||||
The name needs to be unique in each game, the ID needs to be unique across all
|
The name needs to be unique in each game and must not be numeric (has to
|
||||||
games and is best in the same range as the item IDs.
|
contain least 1 letter or symbol). The ID needs to be unique across all games
|
||||||
|
and is best in the same range as the item IDs.
|
||||||
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
World-specific IDs are 1 to 2<sup>53</sup>-1, IDs ≤ 0 are global and reserved.
|
||||||
|
|
||||||
Special locations with ID `None` can hold events.
|
Special locations with ID `None` can hold events.
|
||||||
@@ -121,6 +122,9 @@ their world. Progression items will be assigned to locations with higher
|
|||||||
priority and moved around to meet defined rules and accomplish progression
|
priority and moved around to meet defined rules and accomplish progression
|
||||||
balancing.
|
balancing.
|
||||||
|
|
||||||
|
The name needs to be unique in each game, meaning a duplicate item has the
|
||||||
|
same ID. Name must not be numeric (has to contain at least 1 letter or symbol).
|
||||||
|
|
||||||
Special items with ID `None` can mark events (read below).
|
Special items with ID `None` can mark events (read below).
|
||||||
|
|
||||||
Other classifications include
|
Other classifications include
|
||||||
@@ -188,15 +192,17 @@ the `/worlds` directory. The starting point for the package is `__init.py__`.
|
|||||||
Conventionally, your world class is placed in that file.
|
Conventionally, your world class is placed in that file.
|
||||||
|
|
||||||
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
|
||||||
which can be imported as `..AutoWorld.World` from your package.
|
which can be imported as `worlds.AutoWorld.World` from your package.
|
||||||
|
|
||||||
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
AP will pick up your world automatically due to the `AutoWorld` implementation.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
If your world needs specific python packages, they can be listed in
|
If your world needs specific python packages, they can be listed in
|
||||||
`world/[world_name]/requirements.txt`.
|
`world/[world_name]/requirements.txt`. ModuleUpdate.py will automatically
|
||||||
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
|
pick up and install them.
|
||||||
|
|
||||||
|
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format).
|
||||||
|
|
||||||
### Relative Imports
|
### Relative Imports
|
||||||
|
|
||||||
@@ -209,6 +215,10 @@ e.g. `from .Options import mygame_options` from your `__init__.py` will load
|
|||||||
When imported names pile up it may be easier to use `from . import Options`
|
When imported names pile up it may be easier to use `from . import Options`
|
||||||
and access the variable as `Options.mygame_options`.
|
and access the variable as `Options.mygame_options`.
|
||||||
|
|
||||||
|
Imports from directories outside your world should use absolute imports.
|
||||||
|
Correct use of relative / absolute imports is required for zipped worlds to
|
||||||
|
function, see [apworld specification.md](apworld%20specification.md).
|
||||||
|
|
||||||
### Your Item Type
|
### Your Item Type
|
||||||
|
|
||||||
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
|
||||||
@@ -236,7 +246,7 @@ class MyGameLocation(Location):
|
|||||||
game: str = "My Game"
|
game: str = "My Game"
|
||||||
|
|
||||||
# override constructor to automatically mark event locations as such
|
# override constructor to automatically mark event locations as such
|
||||||
def __init__(self, player: int, name = '', code = None, parent = None):
|
def __init__(self, player: int, name = "", code = None, parent = None):
|
||||||
super(MyGameLocation, self).__init__(player, name, code, parent)
|
super(MyGameLocation, self).__init__(player, name, code, parent)
|
||||||
self.event = code is None
|
self.event = code is None
|
||||||
```
|
```
|
||||||
@@ -252,7 +262,7 @@ to describe it and a `display_name` property for display on the website and in
|
|||||||
spoiler logs.
|
spoiler logs.
|
||||||
|
|
||||||
The actual name as used in the yaml is defined in a `dict[str, Option]`, that is
|
The actual name as used in the yaml is defined in a `dict[str, Option]`, that is
|
||||||
assigned to the world under `self.options`.
|
assigned to the world under `self.option_definitions`.
|
||||||
|
|
||||||
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
|
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
|
||||||
For more see `Options.py` in AP's base directory.
|
For more see `Options.py` in AP's base directory.
|
||||||
@@ -274,14 +284,12 @@ Define a property `option_<name> = <number>` per selectable value and
|
|||||||
`default = <number>` to set the default selection. Aliases can be set by
|
`default = <number>` to set the default selection. Aliases can be set by
|
||||||
defining a property `alias_<name> = <same number>`.
|
defining a property `alias_<name> = <same number>`.
|
||||||
|
|
||||||
One special case where aliases are required is when option name is `yes`, `no`,
|
|
||||||
`on` or `off` because they parse to `True` or `False`:
|
|
||||||
```python
|
```python
|
||||||
option_off = 0
|
option_off = 0
|
||||||
option_on = 1
|
option_on = 1
|
||||||
option_some = 2
|
option_some = 2
|
||||||
alias_false = 0
|
alias_disabled = 0
|
||||||
alias_true = 1
|
alias_enabled = 1
|
||||||
default = 0
|
default = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -323,12 +331,12 @@ mygame_options: typing.Dict[str, type(Option)] = {
|
|||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from ..AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from .Options import mygame_options # import the options dict
|
from .Options import mygame_options # import the options dict
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
#...
|
#...
|
||||||
options = mygame_options # assign the options dict to the world
|
option_definitions = mygame_options # assign the options dict to the world
|
||||||
#...
|
#...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -352,7 +360,7 @@ more natural. These games typically have been edited to 'bake in' the items.
|
|||||||
from .Options import mygame_options # the options we defined earlier
|
from .Options import mygame_options # the options we defined earlier
|
||||||
from .Items import mygame_items # data used below to add items to the World
|
from .Items import mygame_items # data used below to add items to the World
|
||||||
from .Locations import mygame_locations # same as above
|
from .Locations import mygame_locations # same as above
|
||||||
from ..AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
||||||
from Utils import get_options, output_path
|
from Utils import get_options, output_path
|
||||||
|
|
||||||
@@ -365,7 +373,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
|
|||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
"""Insert description of the world/game here."""
|
"""Insert description of the world/game here."""
|
||||||
game: str = "My Game" # name of the game/world
|
game: str = "My Game" # name of the game/world
|
||||||
options = mygame_options # options the player can set
|
option_definitions = mygame_options # options the player can set
|
||||||
topology_present: bool = True # show path to required location checks in spoiler
|
topology_present: bool = True # show path to required location checks in spoiler
|
||||||
remote_items: bool = False # True if all items come from the server
|
remote_items: bool = False # True if all items come from the server
|
||||||
remote_start_inventory: bool = False # True if start inventory comes from the server
|
remote_start_inventory: bool = False # True if start inventory comes from the server
|
||||||
@@ -487,14 +495,14 @@ def create_items(self) -> None:
|
|||||||
for item in map(self.create_item, mygame_items):
|
for item in map(self.create_item, mygame_items):
|
||||||
if item in exclude:
|
if item in exclude:
|
||||||
exclude.remove(item) # this is destructive. create unique list above
|
exclude.remove(item) # this is destructive. create unique list above
|
||||||
self.world.itempool.append(self.create_item('nothing'))
|
self.world.itempool.append(self.create_item("nothing"))
|
||||||
else:
|
else:
|
||||||
self.world.itempool.append(item)
|
self.world.itempool.append(item)
|
||||||
|
|
||||||
# itempool and number of locations should match up.
|
# itempool and number of locations should match up.
|
||||||
# If this is not the case we want to fill the itempool with junk.
|
# If this is not the case we want to fill the itempool with junk.
|
||||||
junk = 0 # calculate this based on player settings
|
junk = 0 # calculate this based on player settings
|
||||||
self.world.itempool += [self.create_item('nothing') for _ in range(junk)]
|
self.world.itempool += [self.create_item("nothing") for _ in range(junk)]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_regions
|
#### create_regions
|
||||||
@@ -553,7 +561,7 @@ def generate_basic(self) -> None:
|
|||||||
### Setting Rules
|
### Setting Rules
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from ..generic.Rules import add_rule, set_rule, forbid_item
|
from worlds.generic.Rules import add_rule, set_rule, forbid_item
|
||||||
from Items import get_item_type
|
from Items import get_item_type
|
||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
@@ -603,7 +611,7 @@ implement more complex logic in logic mixins, even if there is no need to add
|
|||||||
properties to the `BaseClasses.CollectionState` state object.
|
properties to the `BaseClasses.CollectionState` state object.
|
||||||
|
|
||||||
When importing a file that defines a class that inherits from
|
When importing a file that defines a class that inherits from
|
||||||
`..AutoWorld.LogicMixin` the state object's class is automatically extended by
|
`worlds.AutoWorld.LogicMixin` the state object's class is automatically extended by
|
||||||
the mixin's members. These members should be prefixed with underscore following
|
the mixin's members. These members should be prefixed with underscore following
|
||||||
the name of the implementing world. This is due to sharing a namespace with all
|
the name of the implementing world. This is due to sharing a namespace with all
|
||||||
other logic mixins.
|
other logic mixins.
|
||||||
@@ -622,18 +630,18 @@ Please do this with caution and only when neccessary.
|
|||||||
```python
|
```python
|
||||||
# Logic.py
|
# Logic.py
|
||||||
|
|
||||||
from ..AutoWorld import LogicMixin
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
class MyGameLogic(LogicMixin):
|
class MyGameLogic(LogicMixin):
|
||||||
def _mygame_has_key(self, world: MultiWorld, player: int):
|
def _mygame_has_key(self, world: MultiWorld, player: int):
|
||||||
# Arguments above are free to choose
|
# Arguments above are free to choose
|
||||||
# it may make sense to use World as argument instead of MultiWorld
|
# it may make sense to use World as argument instead of MultiWorld
|
||||||
return self.has('key', player) # or whatever
|
return self.has("key", player) # or whatever
|
||||||
```
|
```
|
||||||
```python
|
```python
|
||||||
# __init__.py
|
# __init__.py
|
||||||
|
|
||||||
from ..generic.Rules import set_rule
|
from worlds.generic.Rules import set_rule
|
||||||
import .Logic # apply the mixin by importing its file
|
import .Logic # apply the mixin by importing its file
|
||||||
|
|
||||||
class MyGameWorld(World):
|
class MyGameWorld(World):
|
||||||
|
|||||||
13
host.yaml
13
host.yaml
@@ -101,7 +101,9 @@ sm_options:
|
|||||||
# Alternatively, a path to a program to open the .sfc file with
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
rom_start: true
|
rom_start: true
|
||||||
factorio_options:
|
factorio_options:
|
||||||
executable: "factorio\\bin\\x64\\factorio"
|
executable: "factorio/bin/x64/factorio"
|
||||||
|
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
||||||
|
# server_settings: "factorio\\data\\server-settings.json"
|
||||||
minecraft_options:
|
minecraft_options:
|
||||||
forge_directory: "Minecraft Forge server"
|
forge_directory: "Minecraft Forge server"
|
||||||
max_heap_size: "2G"
|
max_heap_size: "2G"
|
||||||
@@ -127,3 +129,12 @@ smz3_options:
|
|||||||
# True for operating system default program
|
# True for operating system default program
|
||||||
# Alternatively, a path to a program to open the .sfc file with
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
rom_start: true
|
rom_start: true
|
||||||
|
dkc3_options:
|
||||||
|
# File name of the DKC3 US rom
|
||||||
|
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||||
|
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||||
|
sni: "SNI"
|
||||||
|
# Set this to false to never autostart a rom (such as after patching)
|
||||||
|
# True for operating system default program
|
||||||
|
# Alternatively, a path to a program to open the .sfc file with
|
||||||
|
rom_start: true
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
|||||||
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
|
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
|
||||||
Name: "generator"; Description: "Generator"; Types: full hosting
|
Name: "generator"; Description: "Generator"; Types: full hosting
|
||||||
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
|
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||||
@@ -62,6 +63,7 @@ Name: "client"; Description: "Clients"; Types: full playing
|
|||||||
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
Name: "client/sni"; Description: "SNI Client"; Types: full playing
|
||||||
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
|
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||||
@@ -76,6 +78,7 @@ NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-mod
|
|||||||
[Files]
|
[Files]
|
||||||
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
|
||||||
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
|
||||||
|
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
@@ -129,6 +132,7 @@ Type: dirifempty; Name: "{app}"
|
|||||||
|
|
||||||
[InstallDelete]
|
[InstallDelete]
|
||||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||||
|
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||||
|
|
||||||
[Registry]
|
[Registry]
|
||||||
|
|
||||||
@@ -142,6 +146,11 @@ Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archi
|
|||||||
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||||
@@ -187,7 +196,7 @@ begin
|
|||||||
begin
|
begin
|
||||||
// Is the installed version at least the packaged one ?
|
// Is the installed version at least the packaged one ?
|
||||||
Log('VC Redist x64 Version : found ' + strVersion);
|
Log('VC Redist x64 Version : found ' + strVersion);
|
||||||
Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
|
Result := (CompareStr(strVersion, 'v14.32.31332') < 0);
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
begin
|
begin
|
||||||
@@ -205,6 +214,9 @@ var LttPROMFilePage: TInputFileWizardPage;
|
|||||||
var smrom: string;
|
var smrom: string;
|
||||||
var SMRomFilePage: TInputFileWizardPage;
|
var SMRomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
|
var dkc3rom: string;
|
||||||
|
var DKC3RomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
var soerom: string;
|
var soerom: string;
|
||||||
var SoERomFilePage: TInputFileWizardPage;
|
var SoERomFilePage: TInputFileWizardPage;
|
||||||
|
|
||||||
@@ -294,6 +306,8 @@ begin
|
|||||||
Result := not (LttPROMFilePage.Values[0] = '')
|
Result := not (LttPROMFilePage.Values[0] = '')
|
||||||
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
|
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
|
||||||
Result := not (SMROMFilePage.Values[0] = '')
|
Result := not (SMROMFilePage.Values[0] = '')
|
||||||
|
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
|
||||||
|
Result := not (DKC3ROMFilePage.Values[0] = '')
|
||||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||||
Result := not (SoEROMFilePage.Values[0] = '')
|
Result := not (SoEROMFilePage.Values[0] = '')
|
||||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||||
@@ -334,6 +348,22 @@ begin
|
|||||||
Result := '';
|
Result := '';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function GetDKC3ROMPath(Param: string): string;
|
||||||
|
begin
|
||||||
|
if Length(dkc3rom) > 0 then
|
||||||
|
Result := dkc3rom
|
||||||
|
else if Assigned(DKC3RomFilePage) then
|
||||||
|
begin
|
||||||
|
R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947')
|
||||||
|
if R <> 0 then
|
||||||
|
MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||||
|
|
||||||
|
Result := DKC3ROMFilePage.Values[0]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Result := '';
|
||||||
|
end;
|
||||||
|
|
||||||
function GetSoEROMPath(Param: string): string;
|
function GetSoEROMPath(Param: string): string;
|
||||||
begin
|
begin
|
||||||
if Length(soerom) > 0 then
|
if Length(soerom) > 0 then
|
||||||
@@ -378,6 +408,10 @@ begin
|
|||||||
if Length(smrom) = 0 then
|
if Length(smrom) = 0 then
|
||||||
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
|
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
|
||||||
|
|
||||||
|
dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947');
|
||||||
|
if Length(dkc3rom) = 0 then
|
||||||
|
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
|
||||||
|
|
||||||
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
|
||||||
if Length(soerom) = 0 then
|
if Length(soerom) = 0 then
|
||||||
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
|
||||||
@@ -391,6 +425,8 @@ begin
|
|||||||
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
|
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
|
||||||
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
|
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
|
||||||
|
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
|
||||||
|
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||||
|
|||||||
11
kvui.py
11
kvui.py
@@ -334,8 +334,8 @@ class GameManager(App):
|
|||||||
# top part
|
# top part
|
||||||
server_label = ServerLabel()
|
server_label = ServerLabel()
|
||||||
self.connect_layout.add_widget(server_label)
|
self.connect_layout.add_widget(server_label)
|
||||||
self.server_connect_bar = ConnectBarTextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False,
|
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
|
||||||
write_tab=False)
|
height=30, multiline=False, write_tab=False)
|
||||||
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
|
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
|
||||||
self.connect_layout.add_widget(self.server_connect_bar)
|
self.connect_layout.add_widget(self.server_connect_bar)
|
||||||
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
||||||
@@ -412,6 +412,7 @@ class GameManager(App):
|
|||||||
def connect_button_action(self, button):
|
def connect_button_action(self, button):
|
||||||
if self.ctx.server:
|
if self.ctx.server:
|
||||||
self.ctx.server_address = None
|
self.ctx.server_address = None
|
||||||
|
self.ctx.username = None
|
||||||
asyncio.create_task(self.ctx.disconnect())
|
asyncio.create_task(self.ctx.disconnect())
|
||||||
else:
|
else:
|
||||||
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||||
@@ -445,6 +446,12 @@ class GameManager(App):
|
|||||||
self.log_panels["Archipelago"].on_message_markup(text)
|
self.log_panels["Archipelago"].on_message_markup(text)
|
||||||
self.log_panels["All"].on_message_markup(text)
|
self.log_panels["All"].on_message_markup(text)
|
||||||
|
|
||||||
|
def update_address_bar(self, text: str):
|
||||||
|
if hasattr(self, "server_connect_bar"):
|
||||||
|
self.server_connect_bar.text = text
|
||||||
|
else:
|
||||||
|
logging.getLogger("Client").info("Could not update address bar as the GUI is not yet initialized.")
|
||||||
|
|
||||||
def enable_energy_link(self):
|
def enable_energy_link(self):
|
||||||
if not hasattr(self, "energy_link_label"):
|
if not hasattr(self, "energy_link_label"):
|
||||||
self.energy_link_label = Label(text="Energy Link: Standby",
|
self.energy_link_label = Label(text="Energy Link: Standby",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user