mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-22 15:45:04 -07:00
Compare commits
217 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453d89460f | ||
|
|
28889e58aa | ||
|
|
f3c76399e0 | ||
|
|
8384a23fe2 | ||
|
|
bcd7d62d0b | ||
|
|
703f5a22fd | ||
|
|
1ee8e339af | ||
|
|
dffde64079 | ||
|
|
17bc184e28 | ||
|
|
0ba9ee0695 | ||
|
|
c40214e20f | ||
|
|
a3aac3d737 | ||
|
|
7bbe62019a | ||
|
|
b898b9d9e6 | ||
|
|
b217372fea | ||
|
|
b2d2c8e596 | ||
|
|
68e37b8f9a | ||
|
|
fa2d7797f4 | ||
|
|
1885dab066 | ||
|
|
9425f5b772 | ||
|
|
83ed3c8b50 | ||
|
|
f4690e296d | ||
|
|
68c350b4c0 | ||
|
|
da0207f5cb | ||
|
|
2455f1158f | ||
|
|
1031fc4923 | ||
|
|
6beaacb905 | ||
|
|
c46ee7c420 | ||
|
|
227f0bce3d | ||
|
|
611e1c2b19 | ||
|
|
5f974b7457 | ||
|
|
3ef35105c8 | ||
|
|
ec768a2e89 | ||
|
|
b580d3c25a | ||
|
|
ce14f190fb | ||
|
|
4e3da005d4 | ||
|
|
0d9967e8d8 | ||
|
|
2624a0a7ea | ||
|
|
8755d5cbc0 | ||
|
|
abb6d7fbdb | ||
|
|
fc04192c99 | ||
|
|
d4110d3b2a | ||
|
|
05c1751d29 | ||
|
|
6ad042b349 | ||
|
|
e52d8b4dbd | ||
|
|
f288e3469c | ||
|
|
5bb87c6da5 | ||
|
|
03768a5f90 | ||
|
|
a84366368f | ||
|
|
29e6a10e42 | ||
|
|
febd280fba | ||
|
|
73964b374c | ||
|
|
bad6a4b211 | ||
|
|
57d3c52df9 | ||
|
|
d309de2557 | ||
|
|
d5d56ede8b | ||
|
|
6613c29652 | ||
|
|
1a6de25ab6 | ||
|
|
b62c1364a9 | ||
|
|
b59162737d | ||
|
|
543dcb27d8 | ||
|
|
22941168cd | ||
|
|
33dc845de8 | ||
|
|
be0f23beb3 | ||
|
|
b76f2163a4 | ||
|
|
04aa471526 | ||
|
|
b756a67c2a | ||
|
|
a76ee010eb | ||
|
|
eb1fef1f92 | ||
|
|
e498cc7d48 | ||
|
|
a26abe079e | ||
|
|
199b6bdabb | ||
|
|
e4bc7bd1cd | ||
|
|
20651df307 | ||
|
|
f857933748 | ||
|
|
efe2b7c539 | ||
|
|
e090153d93 | ||
|
|
5088b02bfe | ||
|
|
57a716b57a | ||
|
|
1b51714f3b | ||
|
|
cb3d35faf9 | ||
|
|
a0c83b4854 | ||
|
|
1b3ee0e94f | ||
|
|
552a6e7f1c | ||
|
|
38bfb1087b | ||
|
|
2dc55873f0 | ||
|
|
4b1898bfaf | ||
|
|
125bf6f270 | ||
|
|
1873c52aa6 | ||
|
|
ec1e113b4c | ||
|
|
347efac0cd | ||
|
|
b7b5bf58aa | ||
|
|
a324c97815 | ||
|
|
f263a0bc91 | ||
|
|
6a9299018c | ||
|
|
ee471a48bd | ||
|
|
879d7c23b7 | ||
|
|
934b09238e | ||
|
|
1fd8e4435e | ||
|
|
50fd42d0c2 | ||
|
|
399958c881 | ||
|
|
78c93d7e39 | ||
|
|
e3b8a60584 | ||
|
|
b7263edfd0 | ||
|
|
1ee749b352 | ||
|
|
f93734f9e3 | ||
|
|
e211dfa1c2 | ||
|
|
0f7deb1d2a | ||
|
|
f2cb16a5be | ||
|
|
98477e27aa | ||
|
|
4149db1a01 | ||
|
|
9ac921380f | ||
|
|
286e24629f | ||
|
|
ab2efc0c5c | ||
|
|
60d6078e1f | ||
|
|
f94492b2d3 | ||
|
|
f03bb61747 | ||
|
|
dc4e8bae98 | ||
|
|
ac26f8be8b | ||
|
|
8c79499573 | ||
|
|
63fbcc5fc8 | ||
|
|
cad217af19 | ||
|
|
a6ad4a8293 | ||
|
|
503999cb32 | ||
|
|
c2d8f2443e | ||
|
|
4571ed7e2f | ||
|
|
ef5cbd3ba3 | ||
|
|
5c162bd7ce | ||
|
|
7bdaaa25c1 | ||
|
|
9a5a02b654 | ||
|
|
4fea6b6e9b | ||
|
|
bd8b8822ac | ||
|
|
0a44c3ec49 | ||
|
|
3262984386 | ||
|
|
180265c8f4 | ||
|
|
a9b4d33cd2 | ||
|
|
5dfb9b28f7 | ||
|
|
ec75793ac3 | ||
|
|
cd4da36863 | ||
|
|
1749e22569 | ||
|
|
0cce88cfbc | ||
|
|
61e83a300b | ||
|
|
136a13aac7 | ||
|
|
2c90db9ae7 | ||
|
|
507e051a5a | ||
|
|
b5bf9ed1d7 | ||
|
|
215eb7e473 | ||
|
|
f42233699a | ||
|
|
1bec68df4d | ||
|
|
d8576e72eb | ||
|
|
7265468e8d | ||
|
|
d07f36dedd | ||
|
|
364a1b71ec | ||
|
|
daee6d210f | ||
|
|
96be0071e6 | ||
|
|
ff8e1dfb47 | ||
|
|
d26db6f213 | ||
|
|
bb6c753583 | ||
|
|
ca08e4b950 | ||
|
|
5a6b02dbd3 | ||
|
|
14416b1050 | ||
|
|
da4e6fc532 | ||
|
|
57d8b69a6d | ||
|
|
c9d8a8661c | ||
|
|
4a3d23e0e6 | ||
|
|
a3666f2ae5 | ||
|
|
c3e000e574 | ||
|
|
dd5481930a | ||
|
|
842328c661 | ||
|
|
8f75384e2e | ||
|
|
193faa00ce | ||
|
|
5e5383b399 | ||
|
|
cb6b29dbe3 | ||
|
|
82b0819051 | ||
|
|
e12ab4afa4 | ||
|
|
1416f631cc | ||
|
|
dbaac47d1e | ||
|
|
cf0ae5e31b | ||
|
|
8891f07362 | ||
|
|
d78974ec59 | ||
|
|
32be26c4d7 | ||
|
|
9de49aa419 | ||
|
|
294a67a4b4 | ||
|
|
0e99888926 | ||
|
|
74cbf10930 | ||
|
|
08d2909b0e | ||
|
|
0949b11436 | ||
|
|
9cdffe7f63 | ||
|
|
8b2a883669 | ||
|
|
b7fc96100c | ||
|
|
63cbc00a40 | ||
|
|
57b94dba6f | ||
|
|
0dd188e108 | ||
|
|
bf8c840293 | ||
|
|
c0244f3018 | ||
|
|
8af8502202 | ||
|
|
42eaeb92f0 | ||
|
|
7f35eb8867 | ||
|
|
785569c40c | ||
|
|
a9eb70a881 | ||
|
|
5d3d0c8625 | ||
|
|
7e32feeea3 | ||
|
|
0d1935e757 | ||
|
|
9b3ee018e9 | ||
|
|
1de411ec89 | ||
|
|
3192799bbf | ||
|
|
2c8dded52f | ||
|
|
06111ac6cf | ||
|
|
d83294efa7 | ||
|
|
be550ff6fb | ||
|
|
dd55409209 | ||
|
|
e267714d44 | ||
|
|
7c30c4a169 | ||
|
|
4882366ffc | ||
|
|
5f73c245fc | ||
|
|
21ffc0fc54 | ||
|
|
e95a41cf93 |
1
.github/pyright-config.json
vendored
1
.github/pyright-config.json
vendored
@@ -2,6 +2,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"../BizHawkClient.py",
|
"../BizHawkClient.py",
|
||||||
"../Patch.py",
|
"../Patch.py",
|
||||||
|
"../test/param.py",
|
||||||
"../test/general/test_groups.py",
|
"../test/general/test_groups.py",
|
||||||
"../test/general/test_helpers.py",
|
"../test/general/test_helpers.py",
|
||||||
"../test/general/test_memory.py",
|
"../test/general/test_memory.py",
|
||||||
|
|||||||
2
.github/workflows/analyze-modified-files.yml
vendored
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
|||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
- name: "flake8: Lint modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -21,12 +21,17 @@ env:
|
|||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-win: # RCs will still be built and signed by hand
|
build-win: # RCs and releases may still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -65,6 +70,18 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
dist/${{ env.ZIP_NAME }}
|
||||||
|
setups/${{ env.SETUP_NAME }}
|
||||||
- name: Check build loads expected worlds
|
- name: Check build loads expected worlds
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -99,8 +116,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2204:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -132,7 +149,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -142,6 +159,16 @@ jobs:
|
|||||||
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 -
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/${{ env.APPIMAGE_NAME }}*
|
||||||
|
dist/${{ env.TAR_NAME }}
|
||||||
- name: Build Again
|
- name: Build Again
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
|||||||
4
.github/workflows/ctest.yml
vendored
4
.github/workflows/ctest.yml
vendored
@@ -36,9 +36,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ilammy/msvc-dev-cmd@v1
|
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
if: startsWith(matrix.os,'windows')
|
if: startsWith(matrix.os,'windows')
|
||||||
- uses: Bacondish2023/setup-googletest@v1
|
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
||||||
with:
|
with:
|
||||||
build-type: 'Release'
|
build-type: 'Release'
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
|
|||||||
89
.github/workflows/release.yml
vendored
89
.github/workflows/release.yml
vendored
@@ -11,6 +11,11 @@ env:
|
|||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
contents: 'write' # additionally required for release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -26,11 +31,79 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# build-release-windows: # this is done by hand because of signing
|
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu2004:
|
build-release-win:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: windows-latest
|
||||||
|
if: ${{ true }} # change to false to skip if release is built by hand
|
||||||
|
needs: create-release
|
||||||
|
steps:
|
||||||
|
- name: Set env
|
||||||
|
shell: bash
|
||||||
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
# - code below copied from build.yml -
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
|
- name: Download run-time dependencies
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python setup.py build_exe --yes
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "setup.py failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||||
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
|
echo "$NAME -> $ZIP_NAME"
|
||||||
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
New-Item -Path dist -ItemType Directory -Force
|
||||||
|
cd build
|
||||||
|
Rename-Item "exe.$NAME" Archipelago
|
||||||
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Build Setup
|
||||||
|
run: |
|
||||||
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "Building setup failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
|
$SETUP_NAME=$contents[0].Name
|
||||||
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
setups/*
|
||||||
|
- name: Add to Release
|
||||||
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
|
with:
|
||||||
|
draft: true # see above
|
||||||
|
prerelease: false
|
||||||
|
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||||
|
files: |
|
||||||
|
setups/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-release-ubuntu2204:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: create-release
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
@@ -64,7 +137,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -74,6 +147,14 @@ jobs:
|
|||||||
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
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/*
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,11 +4,13 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
|
*.apcivvi
|
||||||
*.apl2ac
|
*.apl2ac
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
|
*.aptww
|
||||||
*.apemerald
|
*.apemerald
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class MultiWorld():
|
|||||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
||||||
for option_key in all_keys:
|
for option_key in all_keys:
|
||||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
||||||
f"Please use `self.options.{option_key}` instead.")
|
f"Please use `self.options.{option_key}` instead.", True)
|
||||||
option.update(getattr(args, option_key, {}))
|
option.update(getattr(args, option_key, {}))
|
||||||
setattr(self, option_key, option)
|
setattr(self, option_key, option)
|
||||||
|
|
||||||
@@ -616,7 +616,7 @@ class MultiWorld():
|
|||||||
locations: Set[Location] = set()
|
locations: Set[Location] = set()
|
||||||
events: Set[Location] = set()
|
events: Set[Location] = set()
|
||||||
for location in self.get_filled_locations():
|
for location in self.get_filled_locations():
|
||||||
if type(location.item.code) is int:
|
if type(location.item.code) is int and type(location.address) is int:
|
||||||
locations.add(location)
|
locations.add(location)
|
||||||
else:
|
else:
|
||||||
events.add(location)
|
events.add(location)
|
||||||
@@ -1022,9 +1022,6 @@ class Entrance:
|
|||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
randomization_group: int
|
randomization_group: int
|
||||||
randomization_type: EntranceType
|
randomization_type: EntranceType
|
||||||
# LttP specific, TODO: should make a LttPEntrance
|
|
||||||
addresses = None
|
|
||||||
target = None
|
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||||
@@ -1043,10 +1040,8 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
def connect(self, region: Region) -> None:
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
|
||||||
self.addresses = addresses
|
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||||
@@ -1106,6 +1101,9 @@ class Region:
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self._list.__len__()
|
return self._list.__len__()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._list)
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
# This seems to not be needed, but that's a bit suspicious.
|
||||||
# def __del__(self):
|
# def __del__(self):
|
||||||
# self.clear()
|
# self.clear()
|
||||||
@@ -1200,6 +1198,48 @@ class Region:
|
|||||||
for location, address in locations.items():
|
for location, address in locations.items():
|
||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
|
def add_event(
|
||||||
|
self,
|
||||||
|
location_name: str,
|
||||||
|
item_name: str | None = None,
|
||||||
|
rule: Callable[[CollectionState], bool] | None = None,
|
||||||
|
location_type: type[Location] | None = None,
|
||||||
|
item_type: type[Item] | None = None,
|
||||||
|
show_in_spoiler: bool = True,
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Adds an event location/item pair to the region.
|
||||||
|
|
||||||
|
:param location_name: Name for the event location.
|
||||||
|
:param item_name: Name for the event item. If not provided, defaults to location_name.
|
||||||
|
:param rule: Callable to determine access for this event location within its region.
|
||||||
|
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
|
||||||
|
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
|
||||||
|
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
|
||||||
|
:return: The created Event Item
|
||||||
|
"""
|
||||||
|
if location_type is None:
|
||||||
|
location_type = Location
|
||||||
|
|
||||||
|
if item_name is None:
|
||||||
|
item_name = location_name
|
||||||
|
|
||||||
|
if item_type is None:
|
||||||
|
item_type = Item
|
||||||
|
|
||||||
|
event_location = location_type(self.player, location_name, None, self)
|
||||||
|
event_location.show_in_spoiler = show_in_spoiler
|
||||||
|
if rule is not None:
|
||||||
|
event_location.access_rule = rule
|
||||||
|
|
||||||
|
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||||
|
|
||||||
|
event_location.place_locked_item(event_item)
|
||||||
|
|
||||||
|
self.locations.append(event_location)
|
||||||
|
|
||||||
|
return event_item
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
@@ -1310,9 +1350,6 @@ class Location:
|
|||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash((self.name, self.player))
|
|
||||||
|
|
||||||
def __lt__(self, other: Location):
|
def __lt__(self, other: Location):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
@@ -1416,6 +1453,10 @@ class Item:
|
|||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_event(self) -> bool:
|
||||||
|
return self.code is None
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Item):
|
if not isinstance(other, Item):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
|
|||||||
@@ -196,25 +196,11 @@ class CommonContext:
|
|||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||||
self.warned: bool = False
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
|
||||||
if isinstance(key, int):
|
|
||||||
if not self.warned:
|
|
||||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
|
||||||
self.warned = True
|
|
||||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
|
||||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
|
||||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
|
||||||
return self._flat_store[key] # type: ignore
|
|
||||||
|
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -254,7 +240,6 @@ class CommonContext:
|
|||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
|
||||||
if game == "Archipelago":
|
if game == "Archipelago":
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||||
# it updates in all chain maps automatically.
|
# it updates in all chain maps automatically.
|
||||||
@@ -356,7 +341,6 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
self.versions = {}
|
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
@@ -413,7 +397,8 @@ class CommonContext:
|
|||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
self.ui.update_hints()
|
if self.ui:
|
||||||
|
self.ui.update_hints()
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
""" `msgs` JSON serializable """
|
""" `msgs` JSON serializable """
|
||||||
@@ -570,7 +555,6 @@ class CommonContext:
|
|||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
remote_date_package_versions: typing.Dict[str, int],
|
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
@@ -579,33 +563,26 @@ class CommonContext:
|
|||||||
|
|
||||||
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_date_package_versions and game not in remote_data_package_checksums:
|
if game not in remote_data_package_checksums:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
if not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cached_version: int = self.versions.get(game, 0)
|
|
||||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if cached version is new enough
|
# no action required if cached version is new enough
|
||||||
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
if remote_checksum != cached_checksum:
|
||||||
or remote_checksum != cached_checksum:
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
if remote_checksum == local_checksum:
|
||||||
and remote_checksum == local_checksum):
|
|
||||||
self.update_game(network_data_package["games"][game], game)
|
self.update_game(network_data_package["games"][game], game)
|
||||||
else:
|
else:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
cache_version: int = cached_game.get("version", 0)
|
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
if remote_checksum != cache_checksum:
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game, game)
|
||||||
@@ -615,7 +592,6 @@ class CommonContext:
|
|||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
self.versions[game] = game_package.get("version", 0)
|
|
||||||
self.checksums[game] = game_package.get("checksum")
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
@@ -624,9 +600,6 @@ class CommonContext:
|
|||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
|
||||||
current_cache.update(data_package["games"])
|
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
@@ -889,9 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update data package
|
||||||
data_package_versions = args.get("datapackage_versions", {})
|
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -1128,7 +1100,7 @@ def run_as_textclient(*args):
|
|||||||
args = handle_url_arg(args, parser=parser)
|
args = handle_url_arg(args, parser=parser)
|
||||||
|
|
||||||
# use colorama to display colored text highlighting on windows
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
parser = get_base_parser()
|
parser = get_base_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.factorio.Client import check_stdin, launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
|
||||||
check_stdin()
|
|
||||||
launch()
|
|
||||||
21
Fill.py
21
Fill.py
@@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
items_to_place.append(reachable_items[next_player].pop())
|
items_to_place.append(reachable_items[next_player].pop())
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
for p, pool_item in enumerate(item_pool):
|
# The items added into `reachable_items` are placed starting from the end of each deque in
|
||||||
|
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
|
||||||
|
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
||||||
if pool_item is item:
|
if pool_item is item:
|
||||||
item_pool.pop(p)
|
del item_pool[-p]
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
@@ -348,10 +350,10 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
|
|||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
location.locked and location.item.player not in minimal_players):
|
location.locked and location.item.player not in minimal_players):
|
||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.advancements:
|
if location in state.advancements:
|
||||||
state.advancements.remove(location)
|
state.advancements.remove(location)
|
||||||
|
state.remove(location.item)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -500,13 +502,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
|
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
|
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority Retry", one_item_per_player=False)
|
name="Priority Retry", one_item_per_player=False)
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
@@ -514,14 +518,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
|
|||||||
58
Generate.py
58
Generate.py
@@ -54,12 +54,22 @@ def mystery_argparse():
|
|||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
|
parser.add_argument("--spoiler_only", action="store_true",
|
||||||
|
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||||
|
"Intended for debugging and testing purposes.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.skip_output and args.spoiler_only:
|
||||||
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
|
elif args.spoiler == 0 and args.spoiler_only:
|
||||||
|
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
|
||||||
|
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
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: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --sameoptions with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
|
|
||||||
|
|
||||||
player_id = 1
|
player_id = 1
|
||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
@@ -164,6 +176,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
erargs.skip_output = args.skip_output
|
erargs.skip_output = args.skip_output
|
||||||
|
erargs.spoiler_only = args.spoiler_only
|
||||||
erargs.name = {}
|
erargs.name = {}
|
||||||
erargs.csv_output = args.csv_output
|
erargs.csv_output = args.csv_output
|
||||||
|
|
||||||
@@ -239,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to read weights ({path})") from e
|
raise Exception(f"Failed to read weights ({path})") from e
|
||||||
|
|
||||||
return tuple(parse_yamls(yaml))
|
from yaml.error import MarkedYAMLError
|
||||||
|
try:
|
||||||
|
return tuple(parse_yamls(yaml))
|
||||||
|
except MarkedYAMLError as ex:
|
||||||
|
if ex.problem_mark:
|
||||||
|
lines = yaml.splitlines()
|
||||||
|
if ex.context_mark:
|
||||||
|
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
||||||
|
else:
|
||||||
|
relevant_lines = lines[ex.problem_mark.line]
|
||||||
|
error_line = " " * ex.problem_mark.column + "^"
|
||||||
|
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
||||||
|
f"\n{relevant_lines}\n{error_line}")
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
|
||||||
def interpret_on_off(value) -> bool:
|
def interpret_on_off(value) -> bool:
|
||||||
@@ -279,22 +305,30 @@ def get_choice(option, root, value=None) -> Any:
|
|||||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||||
|
|
||||||
|
|
||||||
class SafeDict(dict):
|
class SafeFormatter(string.Formatter):
|
||||||
def __missing__(self, key):
|
def get_value(self, key, args, kwargs):
|
||||||
return '{' + key + '}'
|
if isinstance(key, int):
|
||||||
|
if key < len(args):
|
||||||
|
return args[key]
|
||||||
|
else:
|
||||||
|
return "{" + str(key) + "}"
|
||||||
|
else:
|
||||||
|
return kwargs.get(key, "{" + key + "}")
|
||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name.lower()] += 1
|
||||||
number = name_counter[name.lower()]
|
number = name_counter[name.lower()]
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
|
||||||
NUMBER=(number if number > 1 else ''),
|
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
||||||
player=player,
|
"NUMBER": (number if number > 1 else ''),
|
||||||
PLAYER=(player if player > 1 else '')))
|
"player": player,
|
||||||
|
"PLAYER": (player if player > 1 else '')})
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||||
new_name = new_name.strip()[:16].strip()
|
new_name = new_name.strip()[:16].strip()
|
||||||
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
@@ -435,6 +469,14 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
|
"""
|
||||||
|
Roll options from specified weights, usually originating from a .yaml options file.
|
||||||
|
|
||||||
|
Important note:
|
||||||
|
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
|
||||||
|
This means it should never be modified without making a deepcopy first.
|
||||||
|
"""
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
|
|||||||
340
Launcher.py
340
Launcher.py
@@ -1,16 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago launcher for bundled app.
|
Archipelago Launcher
|
||||||
|
|
||||||
* if run with APBP as argument, launch corresponding client.
|
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
||||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
* If run with component name as argument, run it passing argv[2:] as arguments.
|
||||||
* if run without arguments, open launcher GUI
|
* If run without arguments or unknown arguments, open launcher GUI.
|
||||||
|
|
||||||
Scroll down to components= to add components to the launcher as well as setup.py
|
Additional components can be added to worlds.LauncherComponents.components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import shlex
|
import shlex
|
||||||
@@ -20,10 +18,11 @@ import urllib.parse
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
from typing import Callable, Optional, Sequence, Tuple, Union, Any
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -85,12 +84,16 @@ def browse_files():
|
|||||||
def open_folder(folder_path):
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
webbrowser.open(folder_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if exe:
|
||||||
|
subprocess.Popen([exe, folder_path])
|
||||||
|
else:
|
||||||
|
logging.warning(f"No file browser available to open {folder_path}")
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -105,7 +108,8 @@ components.extend([
|
|||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Generate Template Options", func=generate_yamls),
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
Component("Unrated/18+ Discord Server", icon="discord",
|
||||||
|
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Browse Files", func=browse_files),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
|||||||
url = urllib.parse.urlparse(path)
|
url = urllib.parse.urlparse(path)
|
||||||
queries = urllib.parse.parse_qs(url.query)
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
launch_args = (path, *launch_args)
|
launch_args = (path, *launch_args)
|
||||||
client_component = None
|
client_component = []
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
if "game" in queries:
|
if "game" in queries:
|
||||||
game = queries["game"][0]
|
game = queries["game"][0]
|
||||||
@@ -122,49 +126,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
|||||||
game = "Archipelago"
|
game = "Archipelago"
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.supports_uri and component.game_name == game:
|
if component.supports_uri and component.game_name == game:
|
||||||
client_component = component
|
client_component.append(component)
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
|
|
||||||
if client_component is None:
|
from kvui import MDButton, MDButtonText
|
||||||
|
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
|
||||||
|
from kivymd.uix.divider import MDDivider
|
||||||
|
|
||||||
|
if not client_component:
|
||||||
run_component(text_client_component, *launch_args)
|
run_component(text_client_component, *launch_args)
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
|
||||||
|
component_buttons = [MDDivider()]
|
||||||
|
for component in [text_client_component, *client_component]:
|
||||||
|
component_buttons.append(MDButton(
|
||||||
|
MDButtonText(text=component.display_name),
|
||||||
|
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
|
||||||
|
style="text"
|
||||||
|
))
|
||||||
|
component_buttons.append(MDDivider())
|
||||||
|
|
||||||
from kvui import App, Button, BoxLayout, Label, Window
|
MDDialog(
|
||||||
|
# Headline
|
||||||
|
MDDialogHeadlineText(text="Connect to Multiworld"),
|
||||||
|
# Text
|
||||||
|
popup_text,
|
||||||
|
# Content
|
||||||
|
MDDialogContentContainer(
|
||||||
|
*component_buttons,
|
||||||
|
orientation="vertical"
|
||||||
|
),
|
||||||
|
|
||||||
class Popup(App):
|
).open()
|
||||||
def __init__(self):
|
|
||||||
self.title = "Connect to Multiworld"
|
|
||||||
self.icon = r"data/icon.png"
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
layout = BoxLayout(orientation="vertical")
|
|
||||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
|
||||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
|
||||||
|
|
||||||
text_client_button = Button(
|
|
||||||
text=text_client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(text_client_button)
|
|
||||||
|
|
||||||
game_client_button = Button(
|
|
||||||
text=client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(game_client_button)
|
|
||||||
|
|
||||||
layout.add_widget(button_row)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
|
||||||
# see run_gui Launcher _stop comment for details
|
|
||||||
self.root_window.close()
|
|
||||||
super()._stop(*largs)
|
|
||||||
|
|
||||||
Popup().run()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||||
@@ -220,100 +215,189 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
|
def create_shortcut(button: Any, component: Component) -> None:
|
||||||
|
from pyshortcuts import make_shortcut
|
||||||
|
script = sys.argv[0]
|
||||||
|
wkdir = Utils.local_path()
|
||||||
|
|
||||||
|
script = f"{script} \"{component.display_name}\""
|
||||||
|
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
|
||||||
|
startmenu=False, terminal=False, working_dir=wkdir)
|
||||||
|
button.menu.dismiss()
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
refresh_components: Optional[Callable[[], None]] = None
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
def run_gui(path: str, args: Any) -> None:
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivy.metrics import dp
|
||||||
|
from kivymd.uix.button import MDIconButton, MDButton
|
||||||
|
from kivymd.uix.card import MDCard
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||||
|
from kivymd.uix.textfield import MDTextField
|
||||||
|
|
||||||
class Launcher(App):
|
from kivy.lang.builder import Builder
|
||||||
|
|
||||||
|
class LauncherCard(MDCard):
|
||||||
|
component: Component | None
|
||||||
|
image: str
|
||||||
|
context_button: MDIconButton = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||||
|
self.component = component
|
||||||
|
self.image = image_path
|
||||||
|
super().__init__(args, kwargs)
|
||||||
|
|
||||||
|
class Launcher(ThemedApp):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
top_screen: MDFloatLayout = ObjectProperty(None)
|
||||||
grid: GridLayout
|
navigation: MDGridLayout = ObjectProperty(None)
|
||||||
_tool_layout: Optional[ScrollBox] = None
|
grid: MDGridLayout = ObjectProperty(None)
|
||||||
_client_layout: Optional[ScrollBox] = None
|
button_layout: ScrollBox = ObjectProperty(None)
|
||||||
|
search_box: MDTextField = ObjectProperty(None)
|
||||||
|
cards: list[LauncherCard]
|
||||||
|
current_filter: Sequence[str | Type] | None
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None, path=None, args=None):
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
|
self.favorites = []
|
||||||
|
self.launch_uri = path
|
||||||
|
self.launch_args = args
|
||||||
|
self.cards = []
|
||||||
|
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
persistent = Utils.persistent_load()
|
||||||
|
if "launcher" in persistent:
|
||||||
|
if "favorites" in persistent["launcher"]:
|
||||||
|
self.favorites.extend(persistent["launcher"]["favorites"])
|
||||||
|
if "filter" in persistent["launcher"]:
|
||||||
|
if persistent["launcher"]["filter"]:
|
||||||
|
filters = []
|
||||||
|
for filter in persistent["launcher"]["filter"].split(", "):
|
||||||
|
if filter == "favorites":
|
||||||
|
filters.append(filter)
|
||||||
|
else:
|
||||||
|
filters.append(Type[filter])
|
||||||
|
self.current_filter = filters
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _refresh_components(self) -> None:
|
def set_favorite(self, caller):
|
||||||
|
if caller.component.display_name in self.favorites:
|
||||||
|
self.favorites.remove(caller.component.display_name)
|
||||||
|
caller.icon = "star-outline"
|
||||||
|
else:
|
||||||
|
self.favorites.append(caller.component.display_name)
|
||||||
|
caller.icon = "star"
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_card(self, component: Component) -> LauncherCard:
|
||||||
|
"""
|
||||||
|
Builds a card widget for a given component.
|
||||||
|
|
||||||
|
:param component: The component associated with the button.
|
||||||
|
|
||||||
|
:return: The created Card Widget.
|
||||||
"""
|
"""
|
||||||
Builds a button widget for a given component.
|
button_card = LauncherCard(component=component,
|
||||||
|
image_path=icon_paths[component.icon])
|
||||||
|
|
||||||
Args:
|
def open_menu(caller):
|
||||||
component (Component): The component associated with the button.
|
caller.menu.open()
|
||||||
|
|
||||||
Returns:
|
menu_items = [
|
||||||
None. The button is added to the parent grid layout.
|
{
|
||||||
|
"text": "Add shortcut on desktop",
|
||||||
|
"leading_icon": "laptop",
|
||||||
|
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
||||||
|
button_card.context_button.bind(on_release=open_menu)
|
||||||
|
|
||||||
"""
|
return button_card
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
|
||||||
button.component = component
|
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||||
button.bind(on_release=self.component_action)
|
if not type_filter:
|
||||||
if component.icon != "icon":
|
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||||
image = ApAsyncImage(source=icon_paths[component.icon],
|
favorites = "favorites" in type_filter
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
|
||||||
box_layout.add_widget(button)
|
|
||||||
box_layout.add_widget(image)
|
|
||||||
return box_layout
|
|
||||||
return button
|
|
||||||
|
|
||||||
# clear before repopulating
|
# clear before repopulating
|
||||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
assert self.button_layout, "must call `build` first"
|
||||||
tool_children = reversed(self._tool_layout.layout.children)
|
tool_children = reversed(self.button_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self._tool_layout.layout.remove_widget(child)
|
self.button_layout.layout.remove_widget(child)
|
||||||
client_children = reversed(self._client_layout.layout.children)
|
|
||||||
for child in client_children:
|
|
||||||
self._client_layout.layout.remove_widget(child)
|
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
cards = [card for card in self.cards if card.component.type in type_filter
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
or favorites and card.component.display_name in self.favorites]
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
self.current_filter = type_filter
|
||||||
_tools.items(), _miscs.items(), _adjusters.items()
|
|
||||||
), _clients.items()):
|
for card in cards:
|
||||||
# column 1
|
self.button_layout.layout.add_widget(card)
|
||||||
if tool:
|
|
||||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
||||||
# column 2
|
- self.button_layout.height
|
||||||
if client:
|
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
||||||
|
|
||||||
|
def filter_clients_by_type(self, caller: MDButton):
|
||||||
|
self._refresh_components(caller.type)
|
||||||
|
self.search_box.text = ""
|
||||||
|
|
||||||
|
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
||||||
|
if len(name) == 0:
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub_matches = [
|
||||||
|
card for card in self.cards
|
||||||
|
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
||||||
|
]
|
||||||
|
self.button_layout.layout.clear_widgets()
|
||||||
|
for card in sub_matches:
|
||||||
|
self.button_layout.layout.add_widget(card)
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
self.container = ContainerLayout()
|
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = self.top_screen.ids.grid
|
||||||
self.container.add_widget(self.grid)
|
self.navigation = self.top_screen.ids.navigation
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
self.button_layout = self.top_screen.ids.button_layout
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
self.search_box = self.top_screen.ids.search_box
|
||||||
self._tool_layout = ScrollBox()
|
self.set_colors()
|
||||||
self._tool_layout.layout.orientation = "vertical"
|
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||||
self.grid.add_widget(self._tool_layout)
|
|
||||||
self._client_layout = ScrollBox()
|
|
||||||
self._client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._client_layout)
|
|
||||||
|
|
||||||
self._refresh_components()
|
|
||||||
|
|
||||||
global refresh_components
|
global refresh_components
|
||||||
refresh_components = self._refresh_components
|
refresh_components = self._refresh_components
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
Window.bind(on_drop_file=self._on_drop_file)
|
||||||
|
Window.bind(on_keyboard=self._on_keyboard)
|
||||||
|
|
||||||
return self.container
|
for component in components:
|
||||||
|
self.cards.append(self.build_card(component))
|
||||||
|
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
|
||||||
|
# Uncomment to re-enable the Kivy console/live editor
|
||||||
|
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||||
|
# from kivy.modules.console import create_console
|
||||||
|
# create_console(Window, self.top_screen)
|
||||||
|
|
||||||
|
return self.top_screen
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
if self.launch_uri:
|
||||||
|
handle_uri(self.launch_uri, self.launch_args)
|
||||||
|
self.launch_uri = None
|
||||||
|
self.launch_args = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
|
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
if button.component.func:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
@@ -327,13 +411,28 @@ def run_gui():
|
|||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {file}")
|
logging.warning(f"unable to identify component for {file}")
|
||||||
|
|
||||||
|
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
||||||
|
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||||
|
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
||||||
|
# Limit text input to ASCII non-control characters (space bar to tilde).
|
||||||
|
if not self.search_box.focus:
|
||||||
|
self.search_box.focus = True
|
||||||
|
if key in range(32, 126):
|
||||||
|
self.search_box.text += codepoint
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
# Closing the window explicitly cleans it up.
|
# Closing the window explicitly cleans it up.
|
||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
Launcher().run()
|
def on_stop(self):
|
||||||
|
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||||
|
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||||
|
for filter in self.current_filter))
|
||||||
|
super().on_stop()
|
||||||
|
|
||||||
|
Launcher(path=path, args=args).run()
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
# avoiding Launcher reference leak
|
||||||
# and don't try to do something with widgets after window closed
|
# and don't try to do something with widgets after window closed
|
||||||
@@ -360,16 +459,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
|
|
||||||
path = args.get("Patch|Game|Component|url", None)
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path.startswith("archipelago://"):
|
if not path.startswith("archipelago://"):
|
||||||
handle_uri(path, args.get("args", ()))
|
file, component = identify(path)
|
||||||
return
|
if file:
|
||||||
file, component = identify(path)
|
args['file'] = file
|
||||||
if file:
|
if component:
|
||||||
args['file'] = file
|
args['component'] = component
|
||||||
if component:
|
if not component:
|
||||||
args['component'] = component
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
if not component:
|
|
||||||
logging.warning(f"Could not identify Component responsible for {path}")
|
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
@@ -378,7 +475,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif "component" in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui()
|
run_gui(path, args.get("args", ()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -400,6 +497,7 @@ if __name__ == '__main__':
|
|||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
from worlds.LauncherComponents import processes
|
||||||
|
|
||||||
for process in processes:
|
for process in processes:
|
||||||
# we await all child processes to close before we tear down the process host
|
# we await all child processes to close before we tear down the process host
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ import typing
|
|||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||||
server_loop)
|
server_loop)
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
|
from worlds.ladx import LinksAwakeningWorld
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
from worlds.ladx.TrackerConsts import storage_key
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
@@ -51,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def magpie_logo():
|
|
||||||
from kivy.uix.image import CoreImage
|
|
||||||
binary_data = """
|
|
||||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
|
||||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
|
||||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
|
||||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
|
||||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
|
||||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
|
||||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
|
||||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
|
||||||
binary_data = base64.b64decode(binary_data)
|
|
||||||
data = io.BytesIO(binary_data)
|
|
||||||
return CoreImage(data, ext="png").texture
|
|
||||||
|
|
||||||
|
|
||||||
class LAClientConstants:
|
class LAClientConstants:
|
||||||
# Connector version
|
# Connector version
|
||||||
VERSION = 0x01
|
VERSION = 0x01
|
||||||
@@ -139,7 +124,7 @@ class RAGameboy():
|
|||||||
def set_checks_range(self, checks_start, checks_size):
|
def set_checks_range(self, checks_start, checks_size):
|
||||||
self.checks_start = checks_start
|
self.checks_start = checks_start
|
||||||
self.checks_size = checks_size
|
self.checks_size = checks_size
|
||||||
|
|
||||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||||
self.location_start = location_start
|
self.location_start = location_start
|
||||||
self.location_size = location_size
|
self.location_size = location_size
|
||||||
@@ -237,7 +222,7 @@ class RAGameboy():
|
|||||||
self.cache[start:start + len(hram_block)] = hram_block
|
self.cache[start:start + len(hram_block)] = hram_block
|
||||||
|
|
||||||
self.last_cache_read = time.time()
|
self.last_cache_read = time.time()
|
||||||
|
|
||||||
async def read_memory_block(self, address: int, size: int):
|
async def read_memory_block(self, address: int, size: int):
|
||||||
block = bytearray()
|
block = bytearray()
|
||||||
remaining_size = size
|
remaining_size = size
|
||||||
@@ -245,7 +230,7 @@ class RAGameboy():
|
|||||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||||
remaining_size -= len(chunk)
|
remaining_size -= len(chunk)
|
||||||
block += chunk
|
block += chunk
|
||||||
|
|
||||||
return block
|
return block
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
@@ -506,7 +491,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
la_task = None
|
la_task = None
|
||||||
client = None
|
client = None
|
||||||
# TODO: does this need to re-read on reset?
|
# TODO: does this need to re-read on reset?
|
||||||
found_checks = []
|
found_checks = set()
|
||||||
last_resend = time.time()
|
last_resend = time.time()
|
||||||
|
|
||||||
magpie_enabled = False
|
magpie_enabled = False
|
||||||
@@ -514,8 +499,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slot_storage_key(self):
|
def slot_storage_key(self):
|
||||||
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
@@ -529,9 +514,9 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import kvui
|
from kvui import GameManager
|
||||||
from kvui import Button, GameManager
|
from kivy.metrics import dp
|
||||||
from kivy.uix.image import Image
|
from kivymd.uix.button import MDButton, MDButtonText
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
class LADXManager(GameManager):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
@@ -544,25 +529,17 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
b = super().build()
|
b = super().build()
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
if self.ctx.magpie_enabled:
|
||||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
||||||
image = Image(size=(16, 16), texture=magpie_logo())
|
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||||
button.add_widget(image)
|
button.height = self.server_connect_bar.height
|
||||||
|
|
||||||
def set_center(_, center):
|
|
||||||
image.center = center
|
|
||||||
button.bind(center=set_center)
|
|
||||||
|
|
||||||
self.connect_layout.add_widget(button)
|
self.connect_layout.add_widget(button)
|
||||||
|
|
||||||
return b
|
return b
|
||||||
|
|
||||||
self.ui = LADXManager(self)
|
self.ui = LADXManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
async def send_checks(self):
|
|
||||||
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
|
|
||||||
await self.send_msgs(message)
|
|
||||||
|
|
||||||
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||||
# Store the entrances we find on the server for future sessions
|
# Store the entrances we find on the server for future sessions
|
||||||
message = [{
|
message = [{
|
||||||
@@ -601,20 +578,20 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
logger.info("victory!")
|
logger.info("victory!")
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
async def request_found_entrances(self):
|
async def request_found_entrances(self):
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
# Ask for updates so that players can co-op entrances in a seed
|
# Ask for updates so that players can co-op entrances in a seed
|
||||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
if self.ENABLE_DEATHLINK:
|
if self.ENABLE_DEATHLINK:
|
||||||
self.client.pending_deathlink = True
|
self.client.pending_deathlink = True
|
||||||
|
|
||||||
def new_checks(self, item_ids, ladxr_ids):
|
def new_checks(self, item_ids, ladxr_ids):
|
||||||
self.found_checks += item_ids
|
self.found_checks.update(item_ids)
|
||||||
create_task_log_exception(self.send_checks())
|
create_task_log_exception(self.check_locations(self.found_checks))
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||||
|
|
||||||
@@ -642,12 +619,23 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
self.slot_data = args.get("slot_data", {})
|
self.slot_data = args.get("slot_data", {})
|
||||||
|
# This is sent to magpie over local websocket to make its own connection
|
||||||
|
self.slot_data.update({
|
||||||
|
"server_address": self.server_address,
|
||||||
|
"slot_name": self.player_names[self.slot],
|
||||||
|
"password": self.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
# We can process linked items on already-checked checks now that we have slot_data
|
||||||
|
if self.client.tracker:
|
||||||
|
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
|
||||||
|
self.add_linked_items(checked_checks)
|
||||||
|
|
||||||
# TODO - use watcher_event
|
# TODO - use watcher_event
|
||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
self.client.recvd_checks[index] = item
|
self.client.recvd_checks[index] = item
|
||||||
|
|
||||||
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||||
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||||
|
|
||||||
@@ -658,6 +646,13 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
await self.send_msgs(sync_msg)
|
await self.send_msgs(sync_msg)
|
||||||
|
|
||||||
|
def add_linked_items(self, checks: typing.List[Check]):
|
||||||
|
for check in checks:
|
||||||
|
if check.value and check.linkedItem:
|
||||||
|
linkedItem = check.linkedItem
|
||||||
|
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
|
||||||
|
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||||
|
|
||||||
item_id_lookup = get_locations_to_id()
|
item_id_lookup = get_locations_to_id()
|
||||||
|
|
||||||
async def run_game_loop(self):
|
async def run_game_loop(self):
|
||||||
@@ -666,11 +661,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||||
|
|
||||||
for check in ladxr_checks:
|
self.add_linked_items(ladxr_checks)
|
||||||
if check.value and check.linkedItem:
|
|
||||||
linkedItem = check.linkedItem
|
|
||||||
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
|
|
||||||
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
@@ -721,13 +712,15 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
if self.last_resend + 5.0 < now:
|
if self.last_resend + 5.0 < now:
|
||||||
self.last_resend = now
|
self.last_resend = now
|
||||||
await self.send_checks()
|
await self.check_locations(self.found_checks)
|
||||||
if self.magpie_enabled:
|
if self.magpie_enabled:
|
||||||
try:
|
try:
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
self.magpie.slot_data = self.slot_data
|
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
||||||
|
self.magpie.slot_data = self.slot_data
|
||||||
|
await self.magpie.send_slot_data()
|
||||||
|
|
||||||
if self.client.gps_tracker.needs_found_entrances:
|
if self.client.gps_tracker.needs_found_entrances:
|
||||||
await self.request_found_entrances()
|
await self.request_found_entrances()
|
||||||
self.client.gps_tracker.needs_found_entrances = False
|
self.client.gps_tracker.needs_found_entrances = False
|
||||||
@@ -745,8 +738,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
def run_game(romfile: str) -> None:
|
def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = LinksAwakeningWorld.settings.rom_start
|
||||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -803,6 +796,6 @@ async def main():
|
|||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
32
Main.py
32
Main.py
@@ -56,32 +56,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
|
||||||
max_item = 0
|
|
||||||
max_location = 0
|
|
||||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
|
||||||
if cls.item_id_to_name:
|
|
||||||
max_item = max(max_item, max(cls.item_id_to_name))
|
|
||||||
max_location = max(max_location, max(cls.location_id_to_name))
|
|
||||||
|
|
||||||
item_digits = len(str(max_item))
|
|
||||||
location_digits = len(str(max_location))
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"Locations: {len(cls.location_names):{location_count}}")
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
|
||||||
f"{len(cls.location_names):{location_count}} "
|
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
|
||||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
|
||||||
|
|
||||||
del item_digits, location_digits, item_count, location_count
|
del item_count, location_count
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||||
if not args.skip_output:
|
if not args.skip_output and not args.spoiler_only:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -224,6 +210,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
outfilebase = 'AP_' + multiworld.seed_name
|
||||||
|
|
||||||
|
if args.spoiler_only:
|
||||||
|
if args.spoiler > 1:
|
||||||
|
logger.info('Calculating playthrough.')
|
||||||
|
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
|
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||||
|
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
||||||
|
return multiworld
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||||
@@ -306,6 +301,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in multiworld.worlds.values()
|
||||||
}
|
}
|
||||||
|
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ
|
|||||||
SlotType, LocationStore, Hint, HintStatus
|
SlotType, LocationStore, Hint, HintStatus
|
||||||
from BaseClasses import ItemClassification
|
from BaseClasses import ItemClassification
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
|
||||||
colorama.init()
|
min_client_version = Version(0, 5, 0)
|
||||||
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
@@ -66,9 +67,13 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_dict(dictionary, entries):
|
def update_container_unique(container, entries):
|
||||||
dictionary.update(entries)
|
if isinstance(container, list):
|
||||||
return dictionary
|
existing_container_as_set = set(container)
|
||||||
|
container.extend([entry for entry in entries if entry not in existing_container_as_set])
|
||||||
|
else:
|
||||||
|
container.update(entries)
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
def queue_gc():
|
||||||
@@ -109,7 +114,7 @@ modify_functions = {
|
|||||||
# lists/dicts:
|
# lists/dicts:
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_dict,
|
"update": update_container_unique,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1978,11 +1983,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
new_hint = new_hint.re_prioritize(ctx, status)
|
new_hint = new_hint.re_prioritize(ctx, status)
|
||||||
if hint == new_hint:
|
if hint == new_hint:
|
||||||
return
|
return
|
||||||
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
|
|
||||||
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.replace_hint(client.team, slot, hint, new_hint)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.on_changed_hints(client.team, hint.finding_player)
|
for slot in concerning_slots:
|
||||||
ctx.on_changed_hints(client.team, hint.receiving_player)
|
ctx.on_changed_hints(client.team, slot)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
update_client_status(ctx, client, args["status"])
|
update_client_status(ctx, client, args["status"])
|
||||||
@@ -2037,7 +2044,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
ctx.stored_data[args["key"]] = args["value"] = value
|
ctx.stored_data[args["key"]] = args["value"] = value
|
||||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||||
if args.get("want_reply", True):
|
if args.get("want_reply", False):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2412,8 +2419,10 @@ async def console(ctx: Context):
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_settings()["server_options"].as_dict()
|
defaults = get_settings().server_options.as_dict()
|
||||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
112
Options.py
112
Options.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -866,15 +867,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return self.value.__len__()
|
||||||
|
|
||||||
|
# __getitem__ fallback fails for Counters, so we define this explicitly
|
||||||
|
def __contains__(self, item) -> bool:
|
||||||
|
return item in self.value
|
||||||
|
|
||||||
class ItemDict(OptionDict):
|
|
||||||
|
class OptionCounter(OptionDict):
|
||||||
|
min: int | None = None
|
||||||
|
max: int | None = None
|
||||||
|
|
||||||
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
super(OptionCounter, self).__init__(collections.Counter(value))
|
||||||
|
|
||||||
|
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||||
|
super(OptionCounter, self).verify(world, player_name, plando_options)
|
||||||
|
|
||||||
|
range_errors = []
|
||||||
|
|
||||||
|
if self.max is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
|
||||||
|
for key, value in self.value.items() if value > self.max
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.min is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
|
||||||
|
for key, value in self.value.items() if value < self.min
|
||||||
|
]
|
||||||
|
|
||||||
|
if range_errors:
|
||||||
|
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
|
||||||
|
raise OptionError("\n".join(range_errors))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDict(OptionCounter):
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
min = 0
|
||||||
if any(item_count is None for item_count in value.values()):
|
|
||||||
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -1257,42 +1292,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(self,
|
def as_dict(
|
||||||
*option_names: str,
|
self,
|
||||||
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
*option_names: str,
|
||||||
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
|
toggles_as_bools: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
:param option_names: Names of the options to get the values of.
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
||||||
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
|
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
||||||
|
|
||||||
|
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
|
||||||
|
will be returned as a sorted list.
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name not in type(self).type_hints:
|
||||||
if casing == "snake":
|
|
||||||
display_name = option_name
|
|
||||||
elif casing == "camel":
|
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
|
||||||
split_name[0] = split_name[0].lower()
|
|
||||||
display_name = "".join(split_name)
|
|
||||||
elif casing == "pascal":
|
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
|
||||||
elif casing == "kebab":
|
|
||||||
display_name = option_name.replace("_", "-")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
|
||||||
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
|
||||||
value = getattr(self, option_name).value
|
|
||||||
if isinstance(value, set):
|
|
||||||
value = sorted(value)
|
|
||||||
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
|
||||||
value = bool(value)
|
|
||||||
option_results[display_name] = value
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
|
|
||||||
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
|
elif casing == "camel":
|
||||||
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
|
split_name[0] = split_name[0].lower()
|
||||||
|
display_name = "".join(split_name)
|
||||||
|
elif casing == "pascal":
|
||||||
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
|
elif casing == "kebab":
|
||||||
|
display_name = option_name.replace("_", "-")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
||||||
|
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
||||||
|
value = getattr(self, option_name).value
|
||||||
|
if isinstance(value, set):
|
||||||
|
value = sorted(value)
|
||||||
|
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||||
|
value = bool(value)
|
||||||
|
option_results[display_name] = value
|
||||||
return option_results
|
return option_results
|
||||||
|
|
||||||
|
|
||||||
@@ -1313,6 +1353,7 @@ class StartInventory(ItemDict):
|
|||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
|
max = 10000
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
@@ -1579,6 +1620,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
player_output = {
|
player_output = {
|
||||||
"Game": multiworld.game[player],
|
"Game": multiworld.game[player],
|
||||||
"Name": multiworld.get_player_name(player),
|
"Name": multiworld.get_player_name(player),
|
||||||
|
"ID": player,
|
||||||
}
|
}
|
||||||
output.append(player_output)
|
output.append(player_output)
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
@@ -1591,7 +1633,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
game_option_names.append(display_name)
|
game_option_names.append(display_name)
|
||||||
|
|
||||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||||
fields = ["Game", "Name", *all_option_names]
|
fields = ["ID", "Game", "Name", *all_option_names]
|
||||||
writer = DictWriter(file, fields)
|
writer = DictWriter(file, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(output)
|
writer.writerows(output)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Currently, the following games are supported:
|
|||||||
* Factorio
|
* Factorio
|
||||||
* Minecraft
|
* Minecraft
|
||||||
* Subnautica
|
* Subnautica
|
||||||
* Slay the Spire
|
|
||||||
* Risk of Rain 2
|
* Risk of Rain 2
|
||||||
* The Legend of Zelda: Ocarina of Time
|
* The Legend of Zelda: Ocarina of Time
|
||||||
* Timespinner
|
* Timespinner
|
||||||
@@ -63,7 +62,6 @@ Currently, the following games are supported:
|
|||||||
* TUNIC
|
* TUNIC
|
||||||
* Kirby's Dream Land 3
|
* Kirby's Dream Land 3
|
||||||
* Celeste 64
|
* Celeste 64
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
* Castlevania 64
|
||||||
* A Short Hike
|
* A Short Hike
|
||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
@@ -80,6 +78,8 @@ Currently, the following games are supported:
|
|||||||
* Saving Princess
|
* Saving Princess
|
||||||
* Castlevania: Circle of the Moon
|
* Castlevania: Circle of the Moon
|
||||||
* Inscryption
|
* Inscryption
|
||||||
|
* Civilization VI
|
||||||
|
* The Legend of Zelda: The Wind Waker
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -735,6 +735,6 @@ async def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ def main():
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
13
Utils.py
13
Utils.py
@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.6.2"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -114,6 +114,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
|||||||
cache[arg] = res
|
cache[arg] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
wrap.__defaults__ = function.__defaults__
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
@@ -427,6 +429,9 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
|
# used by OptionCounter
|
||||||
|
if module == "collections" and name == "Counter":
|
||||||
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
@@ -630,6 +635,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
import jellyfish
|
import jellyfish
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
|
if word1 == word2:
|
||||||
|
return 1.01
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
/ max(len(word1), len(word2)))
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
@@ -650,8 +657,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
if picks[0][1] == 100:
|
if picks[0][1] == 101:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Case Insensitive Perfect Match"
|
||||||
elif picks[0][1] < 75:
|
elif picks[0][1] < 75:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
|
|||||||
@@ -214,17 +214,11 @@ class WargrooveContext(CommonContext):
|
|||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
|
||||||
from kivy.lang import Builder
|
from kivy.lang import Builder
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.togglebutton import ToggleButton
|
from kivy.uix.togglebutton import ToggleButton
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.image import AsyncImage, Image
|
|
||||||
from kivy.uix.stacklayout import StackLayout
|
|
||||||
from kivy.uix.label import Label
|
from kivy.uix.label import Label
|
||||||
from kivy.properties import ColorProperty
|
|
||||||
from kivy.uix.image import Image
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
|
||||||
class TrackerLayout(BoxLayout):
|
class TrackerLayout(BoxLayout):
|
||||||
@@ -446,6 +440,6 @@ if __name__ == '__main__':
|
|||||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||||
|
|
||||||
args, rest = parser.parse_known_args()
|
args, rest = parser.parse_known_args()
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ def get_seeds():
|
|||||||
response.append({
|
response.append({
|
||||||
"seed_id": seed.id,
|
"seed_id": seed.id,
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed.slots),
|
"players": get_players(seed),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from threading import Event, Thread
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit, PrimaryKey
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
@@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(f"Generator ({sid})")
|
||||||
|
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||||
|
setproctitle(f"Generator (idle)")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(gen_game, (options,),
|
pool.apply_async(_mp_gen_game, (options,),
|
||||||
{"meta": meta,
|
{"meta": meta,
|
||||||
"sid": generation.id,
|
"sid": generation.id,
|
||||||
"owner": generation.owner},
|
"owner": generation.owner},
|
||||||
@@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
|||||||
|
|
||||||
|
|
||||||
def init_generator(config: dict[str, Any]) -> None:
|
def init_generator(config: dict[str, Any]) -> None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle("Generator (idle)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
|
|||||||
@@ -227,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger:
|
|||||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(name)
|
||||||
Utils.init_logging(name)
|
Utils.init_logging(name)
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -247,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
|
||||||
del cert_file, cert_key_file, ponyconfig
|
if not cert_file:
|
||||||
|
def get_ssl_context():
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
load_date = None
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
|
||||||
|
def get_ssl_context():
|
||||||
|
nonlocal load_date, ssl_context
|
||||||
|
today = datetime.date.today()
|
||||||
|
if load_date != today:
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
load_date = today
|
||||||
|
return ssl_context
|
||||||
|
|
||||||
|
del ponyconfig
|
||||||
gc.collect() # free intermediate objects used during setup
|
gc.collect() # free intermediate objects used during setup
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -263,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
assert ctx.server is None
|
assert ctx.server is None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_prog_balancing = False
|
erargs.skip_prog_balancing = False
|
||||||
erargs.skip_output = False
|
erargs.skip_output = False
|
||||||
|
erargs.spoiler_only = False
|
||||||
erargs.csv_output = False
|
erargs.csv_output = False
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ def start_playing():
|
|||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
|
try:
|
||||||
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
if lang not in world.web.game_info_languages:
|
||||||
|
raise KeyError("Sorry, this game's info page is not available in that language yet.")
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +58,12 @@ def games():
|
|||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial(game, file, lang):
|
def tutorial(game, file, lang):
|
||||||
|
try:
|
||||||
|
world = AutoWorldRegister.world_types[game]
|
||||||
|
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
|
||||||
|
raise KeyError("Sorry, the tutorial is not available in that language yet.")
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Dict, Union
|
|||||||
from docutils.core import publish_parts
|
from docutils.core import publish_parts
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from flask import redirect, render_template, request, Response, abort
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# Ensure the option value is valid for Choice and Toggle options
|
||||||
@@ -142,7 +142,10 @@ def weighted_options_old():
|
|||||||
@app.route("/games/<string:game>/weighted-options")
|
@app.route("/games/<string:game>/weighted-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def weighted_options(game: str):
|
def weighted_options(game: str):
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
try:
|
||||||
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||||
@@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str):
|
|||||||
@app.route("/games/<string:game>/player-options")
|
@app.route("/games/<string:game>/player-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def player_options(game: str):
|
def player_options(game: str):
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
try:
|
||||||
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
# YAML generator for player-options
|
||||||
@@ -216,7 +222,7 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build ItemDict options from their name pattern
|
# Detect and build OptionCounter options from their name pattern
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
flask>=3.0.3
|
flask>=3.1.0
|
||||||
werkzeug>=3.0.6
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19
|
pony>=0.7.19
|
||||||
waitress>=3.0.0
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress>=1.17
|
||||||
Flask-Limiter>=3.8.0
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.5.2
|
bokeh>=3.6.3
|
||||||
markupsafe>=2.1.5
|
markupsafe>=3.0.2
|
||||||
Markdown>=3.7
|
Markdown>=3.7
|
||||||
mdx-breakless-lists>=1.0.1
|
mdx-breakless-lists>=1.0.1
|
||||||
|
setproctitle>=1.3.5
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
|
|||||||
showdown.setOption('strikethrough', true);
|
showdown.setOption('strikethrough', true);
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
showdown.setOption('literalMidWordUnderscores', true);
|
||||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
@@ -42,10 +41,5 @@ window.addEventListener('load', () => {
|
|||||||
scrollTarget?.scrollIntoView();
|
scrollTarget?.scrollIntoView();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
gameInfo.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('file-input').addEventListener('change', () => {
|
document.getElementById('file-input').addEventListener('change', () => {
|
||||||
document.getElementById('host-game-form').submit();
|
document.getElementById('host-game-form').submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
adjustFooterHeight();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const adjustFooterHeight = () => {
|
|
||||||
// If there is no footer on this page, do nothing
|
|
||||||
const footer = document.getElementById('island-footer');
|
|
||||||
if (!footer) { return; }
|
|
||||||
|
|
||||||
// If the body is taller than the window, also do nothing
|
|
||||||
if (document.body.offsetHeight > window.innerHeight) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a margin-top to the footer to position it at the bottom of the screen
|
|
||||||
const sibling = footer.previousElementSibling;
|
|
||||||
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
|
|
||||||
if (margin < 1) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
footer.style.marginTop = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustHeaderWidth = () => {
|
|
||||||
// If there is no header, do nothing
|
|
||||||
const header = document.getElementById('base-header');
|
|
||||||
if (!header) { return; }
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
|
||||||
tempDiv.style.width = '100px';
|
|
||||||
tempDiv.style.height = '100px';
|
|
||||||
tempDiv.style.overflow = 'scroll';
|
|
||||||
tempDiv.style.position = 'absolute';
|
|
||||||
tempDiv.style.top = '-500px';
|
|
||||||
document.body.appendChild(tempDiv);
|
|
||||||
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
|
|
||||||
document.body.removeChild(tempDiv);
|
|
||||||
|
|
||||||
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
|
|
||||||
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
|
|
||||||
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
window.addEventListener('resize', adjustFooterHeight);
|
|
||||||
window.addEventListener('resize', adjustHeaderWidth);
|
|
||||||
adjustFooterHeight();
|
|
||||||
adjustHeaderWidth();
|
|
||||||
});
|
|
||||||
@@ -25,7 +25,6 @@ window.addEventListener('load', () => {
|
|||||||
showdown.setOption('literalMidWordUnderscores', true);
|
showdown.setOption('literalMidWordUnderscores', true);
|
||||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
const title = document.querySelector('h1')
|
||||||
if (title) {
|
if (title) {
|
||||||
@@ -49,10 +48,5 @@ window.addEventListener('load', () => {
|
|||||||
scrollTarget?.scrollIntoView();
|
scrollTarget?.scrollIntoView();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
tutorialWrapper.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Page Not Found (404)</title>
|
<title>Page Not Found (404)</title>
|
||||||
@@ -13,5 +14,4 @@
|
|||||||
The page you're looking for doesn't exist.<br />
|
The page you're looking for doesn't exist.<br />
|
||||||
<a href="/">Click here to return to safety.</a>
|
<a href="/">Click here to return to safety.</a>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -27,6 +28,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -57,5 +58,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,26 +5,29 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
||||||
<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/globalStyles.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
|
<div class="user-message">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% block body %}
|
||||||
{% if messages %}
|
{% endblock %}
|
||||||
<div>
|
</main>
|
||||||
{% for message in messages | unique %}
|
|
||||||
<div class="user-message">{{ message }}</div>
|
{% if show_footer %}
|
||||||
{% endfor %}
|
{% include "islandFooter.html" %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -111,10 +111,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option) %}
|
{% macro OptionCounter(option_name, option) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
@@ -213,7 +222,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomizeButton(option_name, option) %}
|
{% macro RandomizeButton(option_name, option) %}
|
||||||
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
|
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
|
||||||
<label for="random-{{ option_name }}">
|
<label for="random-{{ option_name }}">
|
||||||
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
||||||
🎲
|
🎲
|
||||||
|
|||||||
@@ -93,8 +93,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -133,8 +135,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
@@ -15,5 +16,4 @@
|
|||||||
{{ seed_error }}
|
{{ seed_error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -26,6 +27,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<div id="user-content-wrapper" class="markdown">
|
<div id="user-content-wrapper" class="markdown">
|
||||||
<div id="user-content" class="grass-island">
|
<div id="user-content" class="grass-island">
|
||||||
<h1>User Content</h1>
|
<h1>User Content</h1>
|
||||||
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
|
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
|
||||||
|
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
|
||||||
|
|
||||||
<h2>Your Rooms</h2>
|
<h2>Your Rooms</h2>
|
||||||
{% if rooms %}
|
{% if rooms %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>View Seed {{ seed.id|suuid }}</title>
|
<title>View Seed {{ seed.id|suuid }}</title>
|
||||||
@@ -50,5 +51,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation in Progress</title>
|
<title>Generation in Progress</title>
|
||||||
<meta http-equiv="refresh" content="1">
|
<noscript>
|
||||||
|
<meta http-equiv="refresh" content="1">
|
||||||
|
</noscript>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -15,5 +18,34 @@
|
|||||||
Waiting for game to generate, this page auto-refreshes to check.
|
Waiting for game to generate, this page auto-refreshes to check.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
<script>
|
||||||
|
const waitSeedDiv = document.getElementById("wait-seed");
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
|
||||||
|
if (response.status !== 202) {
|
||||||
|
// Seed is ready; reload page to load seed page.
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Generation in Progress</h1>
|
||||||
|
<p>${data.text}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000); // Continue polling.
|
||||||
|
} catch (error) {
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Progress Unknown</h1>
|
||||||
|
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -113,9 +113,18 @@
|
|||||||
{{ TextChoice(option_name, option) }}
|
{{ TextChoice(option_name, option) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option, world) %}
|
{% macro OptionCounter(option_name, option, world) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="dict-container">
|
<div class="dict-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="dict-entry">
|
<div class="dict-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -83,8 +83,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option, world) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="unsupported-option">
|
<div class="unsupported-option">
|
||||||
This option is not supported. Please edit your .yaml file manually.
|
This option cannot be modified here. Please edit your .yaml file manually.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ if __name__ == '__main__':
|
|||||||
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')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -14,23 +14,60 @@
|
|||||||
salmon: "FA8072" # typically trap item
|
salmon: "FA8072" # typically trap item
|
||||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||||
orange: "FF7700" # Used for command echo
|
orange: "FF7700" # Used for command echo
|
||||||
<Label>:
|
# KivyMD theming parameters
|
||||||
color: "FFFFFF"
|
theme_style: "Dark" # Light/Dark
|
||||||
<TabbedPanel>:
|
primary_palette: "Lightsteelblue" # Many options
|
||||||
tab_width: root.width / app.tab_count
|
dynamic_scheme_name: "VIBRANT"
|
||||||
|
dynamic_scheme_contrast: 0.0
|
||||||
|
<MDLabel>:
|
||||||
|
color: self.theme_cls.primaryColor
|
||||||
|
<BaseButton>:
|
||||||
|
ripple_color: app.theme_cls.primaryColor
|
||||||
|
ripple_duration_in_fast: 0.2
|
||||||
|
<MDTabsItemBase>:
|
||||||
|
ripple_color: app.theme_cls.primaryColor
|
||||||
|
ripple_duration_in_fast: 0.2
|
||||||
<TooltipLabel>:
|
<TooltipLabel>:
|
||||||
text_size: self.width, None
|
adaptive_height: True
|
||||||
size_hint_y: None
|
theme_font_size: "Custom"
|
||||||
height: self.texture_size[1]
|
font_size: "20dp"
|
||||||
font_size: dp(20)
|
|
||||||
markup: True
|
markup: True
|
||||||
|
halign: "left"
|
||||||
<SelectableLabel>:
|
<SelectableLabel>:
|
||||||
|
size_hint: 1, None
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
|
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
|
<MarkupDropdownItem>
|
||||||
|
orientation: "vertical"
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: root.text
|
||||||
|
valign: "center"
|
||||||
|
padding_x: "12dp"
|
||||||
|
shorten: True
|
||||||
|
shorten_from: "right"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
markup: True
|
||||||
|
text_color:
|
||||||
|
app.theme_cls.onSurfaceVariantColor \
|
||||||
|
if not root.text_color else \
|
||||||
|
root.text_color
|
||||||
|
|
||||||
|
MDDivider:
|
||||||
|
md_bg_color:
|
||||||
|
( \
|
||||||
|
app.theme_cls.outlineVariantColor \
|
||||||
|
if not root.divider_color \
|
||||||
|
else root.divider_color \
|
||||||
|
) \
|
||||||
|
if root.divider else \
|
||||||
|
(0, 0, 0, 0)
|
||||||
<UILog>:
|
<UILog>:
|
||||||
messages: 1000 # amount of messages stored in client logs.
|
messages: 1000 # amount of messages stored in client logs.
|
||||||
cols: 1
|
cols: 1
|
||||||
@@ -49,7 +86,7 @@
|
|||||||
<HintLabel>:
|
<HintLabel>:
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
|
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
@@ -126,9 +163,12 @@
|
|||||||
<ToolTip>:
|
<ToolTip>:
|
||||||
size: self.texture_size
|
size: self.texture_size
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
|
theme_font_size: "Custom"
|
||||||
font_size: dp(18)
|
font_size: dp(18)
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
halign: "left"
|
halign: "left"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: (1, 1, 1, 1)
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: 0.2, 0.2, 0.2, 1
|
rgba: 0.2, 0.2, 0.2, 1
|
||||||
@@ -147,8 +187,38 @@
|
|||||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||||
<ServerToolTip>:
|
<ServerToolTip>:
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
<AutocompleteHintInput>
|
<AutocompleteHintInput>:
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: "30dp"
|
||||||
multiline: False
|
multiline: False
|
||||||
write_tab: False
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<ConnectBarTextInput>:
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
role: "medium"
|
||||||
|
size_hint_y: None
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<CommandPromptTextInput>:
|
||||||
|
size_hint_y: None
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<MessageBoxLabel>:
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
|
<ScrollBox>:
|
||||||
|
layout: layout
|
||||||
|
bar_width: "12dp"
|
||||||
|
scroll_wheel_distance: 40
|
||||||
|
do_scroll_x: False
|
||||||
|
scroll_type: ['bars', 'content']
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
id: layout
|
||||||
|
orientation: "vertical"
|
||||||
|
spacing: 10
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|||||||
161
data/launcher.kv
Normal file
161
data/launcher.kv
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<LauncherCard>:
|
||||||
|
id: main
|
||||||
|
style: "filled"
|
||||||
|
padding: "4dp"
|
||||||
|
size_hint: 1, None
|
||||||
|
height: "75dp"
|
||||||
|
context_button: context
|
||||||
|
focus_behavior: False
|
||||||
|
|
||||||
|
MDRelativeLayout:
|
||||||
|
ApAsyncImage:
|
||||||
|
source: main.image
|
||||||
|
size: (48, 48)
|
||||||
|
size_hint: None, None
|
||||||
|
pos_hint: {"center_x": 0.1, "center_y": 0.5}
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.display_name
|
||||||
|
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
|
||||||
|
halign: "center"
|
||||||
|
font_style: "Title"
|
||||||
|
role: "medium"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.description
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.35}
|
||||||
|
halign: "center"
|
||||||
|
role: "small"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
component: main.component
|
||||||
|
icon: "star" if self.component.display_name in app.favorites else "star-outline"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.85, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
on_release: app.set_favorite(self)
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
id: context
|
||||||
|
icon: "menu"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.95, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
pos_hint:{"center_x": 0.9, "center_y": 0.25}
|
||||||
|
size_hint_y: None
|
||||||
|
height: "25dp"
|
||||||
|
component: main.component
|
||||||
|
on_release: app.component_action(self)
|
||||||
|
detect_visible: False
|
||||||
|
MDButtonText:
|
||||||
|
text: "Open"
|
||||||
|
|
||||||
|
|
||||||
|
#:import Type worlds.LauncherComponents.Type
|
||||||
|
MDFloatLayout:
|
||||||
|
id: top_screen
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: grid
|
||||||
|
cols: 2
|
||||||
|
spacing: "5dp"
|
||||||
|
padding: "10dp"
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: navigation
|
||||||
|
cols: 1
|
||||||
|
size_hint_x: 0.25
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: all
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "asterisk"
|
||||||
|
MDButtonText:
|
||||||
|
text: "All"
|
||||||
|
MDButton:
|
||||||
|
id: client
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "controller"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Client"
|
||||||
|
MDButton:
|
||||||
|
id: Tool
|
||||||
|
style: "text"
|
||||||
|
type: (Type.TOOL, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "desktop-classic"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Tool"
|
||||||
|
MDButton:
|
||||||
|
id: adjuster
|
||||||
|
style: "text"
|
||||||
|
type: (Type.ADJUSTER, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "wrench"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Adjuster"
|
||||||
|
MDButton:
|
||||||
|
id: misc
|
||||||
|
style: "text"
|
||||||
|
type: (Type.MISC, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "dots-horizontal-circle-outline"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Misc"
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: favorites
|
||||||
|
style: "text"
|
||||||
|
type: ("favorites", )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "star"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Favorites"
|
||||||
|
|
||||||
|
MDNavigationDrawerDivider:
|
||||||
|
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: main_layout
|
||||||
|
cols: 1
|
||||||
|
spacing: "10dp"
|
||||||
|
|
||||||
|
MDTextField:
|
||||||
|
id: search_box
|
||||||
|
mode: "outlined"
|
||||||
|
set_text: app.filter_clients_by_name
|
||||||
|
|
||||||
|
MDTextFieldLeadingIcon:
|
||||||
|
icon: "magnify"
|
||||||
|
|
||||||
|
MDTextFieldHintText:
|
||||||
|
text: "Search"
|
||||||
|
|
||||||
|
ScrollBox:
|
||||||
|
id: button_layout
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -45,6 +45,9 @@
|
|||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
/worlds/checksfinder/ @SunCatMC
|
/worlds/checksfinder/ @SunCatMC
|
||||||
|
|
||||||
|
# Civilization VI
|
||||||
|
/worlds/civ6/ @hesto2
|
||||||
|
|
||||||
# Clique
|
# Clique
|
||||||
/worlds/clique/ @ThePhar
|
/worlds/clique/ @ThePhar
|
||||||
|
|
||||||
@@ -181,9 +184,6 @@
|
|||||||
# Secret of Evermore
|
# Secret of Evermore
|
||||||
/worlds/soe/ @black-sliver
|
/worlds/soe/ @black-sliver
|
||||||
|
|
||||||
# Slay the Spire
|
|
||||||
/worlds/spire/ @KonoTyran
|
|
||||||
|
|
||||||
# Stardew Valley
|
# Stardew Valley
|
||||||
/worlds/stardew_valley/ @agilbert1412
|
/worlds/stardew_valley/ @agilbert1412
|
||||||
|
|
||||||
@@ -211,6 +211,9 @@
|
|||||||
# Wargroove
|
# Wargroove
|
||||||
/worlds/wargroove/ @FlySniper
|
/worlds/wargroove/ @FlySniper
|
||||||
|
|
||||||
|
# The Wind Waker
|
||||||
|
/worlds/tww/ @tanjo3
|
||||||
|
|
||||||
# The Witness
|
# The Witness
|
||||||
/worlds/witness/ @NewSoupVi @blastron
|
/worlds/witness/ @NewSoupVi @blastron
|
||||||
|
|
||||||
@@ -226,10 +229,6 @@
|
|||||||
# Zillion
|
# Zillion
|
||||||
/worlds/zillion/ @beauxq
|
/worlds/zillion/ @beauxq
|
||||||
|
|
||||||
# Zork Grand Inquisitor
|
|
||||||
/worlds/zork_grand_inquisitor/ @nbrochu
|
|
||||||
|
|
||||||
|
|
||||||
## Active Unmaintained Worlds
|
## Active Unmaintained Worlds
|
||||||
|
|
||||||
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
|
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Adding Games
|
# Adding Games
|
||||||
|
|
||||||
|
Like all contributions to Archipelago, New Game implementations should follow the [Contributing](/docs/contributing.md)
|
||||||
|
guide.
|
||||||
|
|
||||||
Adding a new game to Archipelago has two major parts:
|
Adding a new game to Archipelago has two major parts:
|
||||||
|
|
||||||
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
|
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
|
||||||
@@ -13,30 +16,51 @@ it will not be detailed here.
|
|||||||
|
|
||||||
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
||||||
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
||||||
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
|
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
|
||||||
to behave as expected are:
|
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
|
||||||
|
|
||||||
|
### Hard Requirements
|
||||||
|
|
||||||
|
In order for the game client to behave as expected, it must be able to perform these functions:
|
||||||
|
|
||||||
* Handle both secure and unsecure websocket connections
|
* Handle both secure and unsecure websocket connections
|
||||||
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
|
* Reconnect if the connection is unstable and lost while playing
|
||||||
* Receive and parse network packets when the player receives an item from the server, and reward it to the player on
|
|
||||||
demand
|
|
||||||
* **Any** of your items can be received any number of times, up to and far surpassing those that the game might
|
|
||||||
normally expect from features such as starting inventory, item link replacement, or item cheating
|
|
||||||
* Players and the admin can cheat items to the player at any time with a server command, and these items may not have
|
|
||||||
a player or location attributed to them
|
|
||||||
* Be able to change the port for saved connection info
|
* Be able to change the port for saved connection info
|
||||||
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
|
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
|
||||||
privilege can be lost, requiring the room to be moved to a new port
|
privilege can be lost, requiring the room to be moved to a new port
|
||||||
* Reconnect if the connection is unstable and lost while playing
|
|
||||||
* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed
|
|
||||||
order.
|
|
||||||
* Receive items that were sent to the player while they were not connected to the server
|
|
||||||
* The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not
|
|
||||||
strictly required
|
|
||||||
* Send a status update packet alerting the server that the player has completed their goal
|
* Send a status update packet alerting the server that the player has completed their goal
|
||||||
|
|
||||||
Libraries for most modern languages and the spec for various packets can be found in the
|
Regarding items and locations, the game client must be able to handle these tasks:
|
||||||
[network protocol](/docs/network%20protocol.md) API reference document.
|
|
||||||
|
#### Location Handling
|
||||||
|
|
||||||
|
Send a network packet to the server when it detects a location has been "checked" by the player in-game.
|
||||||
|
|
||||||
|
* If actions were taken in game that would usually trigger a location check, and those actions can only ever be taken
|
||||||
|
once, but the client was not connected when they happened: The client must send those location checks on connection
|
||||||
|
so that they are not permanently lost, e.g. by reading flags in the game state or save file.
|
||||||
|
|
||||||
|
#### Item Handling
|
||||||
|
|
||||||
|
Receive and parse network packets from the server when the player receives an item.
|
||||||
|
|
||||||
|
* It must reward items to the player on demand, as items can come from other players at any time.
|
||||||
|
* It must be able to reward copies of an item, up to and beyond the number the game normally expects. This may happen
|
||||||
|
due to features such as starting inventory, item link replacement, admin commands, or item cheating. **Any** of
|
||||||
|
your items can be received **any** number of times.
|
||||||
|
* Admins and players may use server commands to create items without a player or location attributed to them. The
|
||||||
|
client must be able to handle these items.
|
||||||
|
* It must keep an index for items received in order to resync. The ItemsReceived Packets are a single list with a
|
||||||
|
guaranteed order.
|
||||||
|
* It must be able to receive items that were sent to the player while they were not connected to the server.
|
||||||
|
|
||||||
|
### Encouraged Features
|
||||||
|
|
||||||
|
These are "nice to have" features for a client, but they are not strictly required. It is encouraged to add them
|
||||||
|
if possible.
|
||||||
|
|
||||||
|
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
||||||
|
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
||||||
|
|
||||||
## World
|
## World
|
||||||
|
|
||||||
@@ -44,35 +68,94 @@ The world is your game integration for the Archipelago generator, webhost, and m
|
|||||||
information necessary for creating the items and locations to be randomized, the logic for item placement, the
|
information necessary for creating the items and locations to be randomized, the logic for item placement, the
|
||||||
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
|
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
|
||||||
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
|
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
|
||||||
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
|
repository and creating a new world package in `/worlds/`.
|
||||||
following requirements:
|
|
||||||
|
|
||||||
* A folder within `/worlds/` that contains an `__init__.py`
|
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
||||||
* A `World` subclass where you create your world and define all of its rules
|
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
||||||
* A unique game name
|
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
|
||||||
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
|
check out [world maintainer.md](/docs/world%20maintainer.md).
|
||||||
definition
|
|
||||||
* The game_info doc must follow the format `{language_code}_{game_name}.md`
|
### Hard Requirements
|
||||||
|
|
||||||
|
A bare minimum world implementation must satisfy the following requirements:
|
||||||
|
|
||||||
|
* It has a folder with the name of your game (or an abbreviation) under `/worlds/`
|
||||||
|
* The `/worlds/{game}` folder contains an `__init__.py`
|
||||||
|
* Any subfolders within `/worlds/{game}` that contain `*.py` files also contain an `__init__.py` for frozen build
|
||||||
|
packaging
|
||||||
|
* The game folder has at least one game_info doc named with follow the format `{language_code}_{game_name}.md`
|
||||||
|
* The game folder has at least one setup doc
|
||||||
|
* There must be a `World` subclass in your game folder (typically in `/worlds/{game}/__init__.py`) where you create
|
||||||
|
your world and define all of its rules and features
|
||||||
|
|
||||||
|
Within the `World` subclass you should also have:
|
||||||
|
|
||||||
|
* A [unique game name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L260)
|
||||||
|
* An [instance](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295) of a `WebWorld`
|
||||||
|
subclass for webhost documentation and behaviors
|
||||||
|
* In your `WebWorld`, if you wrote a game_info doc in more than one language, override the list of
|
||||||
|
[game info languages](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L210) with the
|
||||||
|
ones you include.
|
||||||
|
* In your `WebWorld`, override the list of
|
||||||
|
[tutorials](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L213) with each tutorial
|
||||||
|
or setup doc you included in the game folder.
|
||||||
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
|
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
|
||||||
`item_name_to_id` and `location_name_to_id`, respectively.
|
`item_name_to_id` and `location_name_to_id`, respectively.
|
||||||
* Create an item when `create_item` is called both by your code and externally
|
* An implementation of `create_item` that can create an item when called by either your code or by another process
|
||||||
* An `options_dataclass` defining the options players have available to them
|
within Archipelago
|
||||||
* A `Region` for your player with the name "Menu" to start from
|
* At least one `Region` for your player to start from (i.e. the Origin Region)
|
||||||
* Create a non-zero number of locations and add them to your regions
|
* The default name of this region is "Menu" but you may configure a different name with
|
||||||
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
|
[origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
|
||||||
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific
|
* A non-zero number of locations, added to your regions
|
||||||
items, there are multiple ways to do so, but they should not be added to the multiworld itempool.
|
* A non-zero number of items **equal** to the number of locations, added to the multiworld itempool
|
||||||
|
* In rare cases, there may be 0-location-0-item games, but this is extremely atypical.
|
||||||
|
* A set
|
||||||
|
[completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for
|
||||||
|
the player.
|
||||||
|
* Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal.
|
||||||
|
|
||||||
Notable caveats:
|
### Encouraged Features
|
||||||
* The "Menu" region will always be considered the "start" for the player
|
|
||||||
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the
|
These are "nice to have" features for a world, but they are not strictly required. It is encouraged to add them
|
||||||
|
if possible.
|
||||||
|
|
||||||
|
* An implementation of
|
||||||
|
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
|
||||||
|
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
|
||||||
|
filler items.
|
||||||
|
* An `options_dataclass` defining the options players have available to them
|
||||||
|
* This should be accompanied by a type hint for `options` with the same class name
|
||||||
|
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
|
||||||
|
* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226)
|
||||||
|
for better organization on the webhost
|
||||||
|
* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223)
|
||||||
|
for player convenience
|
||||||
|
* A dictionary of [item name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L273)
|
||||||
|
for player convenience
|
||||||
|
* A dictionary of
|
||||||
|
[location name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L276)
|
||||||
|
for player convenience
|
||||||
|
* Other games may also benefit from your name group dictionaries for hints, features, etc.
|
||||||
|
|
||||||
|
### Discouraged or Prohibited Behavior
|
||||||
|
|
||||||
|
These are behaviors or implementations that are known to cause various issues. Some of these points have notable
|
||||||
|
workarounds or preferred methods which should be used instead:
|
||||||
|
|
||||||
|
* All items submitted to the multiworld itempool must not be manually placed by the World.
|
||||||
|
* If you need to place specific items, there are multiple ways to do so, but they should not be added to the
|
||||||
|
multiworld itempool.
|
||||||
|
* It is not allowed to use `eval` for most reasons, chiefly due to security concerns.
|
||||||
|
* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns.
|
||||||
|
* When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser.
|
||||||
|
* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively),
|
||||||
|
do **not** use `=` as this will overwrite all elements for all games in the seed.
|
||||||
|
* Instead, use `append`, `extend`, or `+=`.
|
||||||
|
|
||||||
|
### Notable Caveats
|
||||||
|
|
||||||
|
* The Origin Region will always be considered the "start" for the player
|
||||||
|
* The Origin Region is *always* considered accessible; i.e. the player is expected to always be able to return to the
|
||||||
start of the game from anywhere
|
start of the game from anywhere
|
||||||
* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use
|
|
||||||
`append`, `extend`, or `+=`. **Do not use `=`**
|
|
||||||
* Regions are simply containers for locations that share similar access rules. They do not have to map to
|
* Regions are simply containers for locations that share similar access rules. They do not have to map to
|
||||||
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
|
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
|
||||||
|
|
||||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during
|
|
||||||
generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
|
||||||
regarding the API can be found in the [world api doc](/docs/world%20api.md).
|
|
||||||
Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md).
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
|
|||||||
|
|
||||||
### My game has a restrictive start that leads to fill errors
|
### My game has a restrictive start that leads to fill errors
|
||||||
|
|
||||||
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
|
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
|
||||||
|
than one item to get a player to sphere 2.
|
||||||
|
|
||||||
|
One way to fix this is to hint to the Generator that an item needs to be in sphere one with local_early_items.
|
||||||
|
Here, `1` represents the number of "Sword" items the Generator will attempt to place in sphere one.
|
||||||
```py
|
```py
|
||||||
early_item_name = "Sword"
|
early_item_name = "Sword"
|
||||||
self.multiworld.local_early_items[self.player][early_item_name] = 1
|
self.multiworld.local_early_items[self.player][early_item_name] = 1
|
||||||
@@ -18,15 +22,19 @@ Some alternative ways to try to fix this problem are:
|
|||||||
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
|
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
|
||||||
* Pre-place items yourself, such as during `create_items`
|
* Pre-place items yourself, such as during `create_items`
|
||||||
* Put items into the player's starting inventory using `push_precollected`
|
* Put items into the player's starting inventory using `push_precollected`
|
||||||
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
|
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a
|
||||||
|
restrictive start
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### I have multiple settings that change the item/location pool counts and need to balance them out
|
### I have multiple options that change the item/location pool counts and need to make sure I am not submitting more/fewer items than locations
|
||||||
|
|
||||||
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
|
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
|
||||||
|
unbalanced. But in real, complex situations, that might be unfeasible.
|
||||||
|
|
||||||
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
|
If that's the case, you can create extra filler based on the difference between your unfilled locations and your
|
||||||
|
itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations)
|
||||||
|
to your list of items to submit
|
||||||
|
|
||||||
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
|
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
|
||||||
```py
|
```py
|
||||||
@@ -39,7 +47,8 @@ for _ in range(total_locations - len(item_pool)):
|
|||||||
self.multiworld.itempool += item_pool
|
self.multiworld.itempool += item_pool
|
||||||
```
|
```
|
||||||
|
|
||||||
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
A faster alternative to the `for` loop would be to use a
|
||||||
|
[list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
||||||
```py
|
```py
|
||||||
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
|
||||||
```
|
```
|
||||||
@@ -48,21 +57,68 @@ item_pool += [self.create_filler() for _ in range(total_locations - len(item_poo
|
|||||||
|
|
||||||
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
|
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
|
||||||
|
|
||||||
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
|
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and
|
||||||
|
**when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is
|
||||||
|
quite complicated.
|
||||||
|
|
||||||
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check.
|
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph.
|
||||||
|
It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to
|
||||||
|
the queue until there is nothing more to check.
|
||||||
|
|
||||||
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen:
|
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region
|
||||||
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
|
access, then the following may happen:
|
||||||
|
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been
|
||||||
|
reached yet during the graph search.
|
||||||
2. Then, the region in its access_rule is determined to be reachable.
|
2. Then, the region in its access_rule is determined to be reachable.
|
||||||
|
|
||||||
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
|
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
|
||||||
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
|
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new
|
||||||
|
regions are reached.
|
||||||
|
|
||||||
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
|
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
|
||||||
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
|
if a specific region is reached during it.
|
||||||
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region.
|
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
|
||||||
|
using them is significantly faster than just "rechecking each entrance until nothing new is found".
|
||||||
|
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they
|
||||||
|
call `region.can_reach` on their respective parent/source region.
|
||||||
|
|
||||||
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
|
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition,
|
||||||
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
|
and that some games have very complex access rules.
|
||||||
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are much faster.
|
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682)
|
||||||
|
being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of
|
||||||
|
checking each entrance whenever a region has been reached, although this does come with a performance cost.
|
||||||
|
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should
|
||||||
|
be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are
|
||||||
|
much faster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata
|
||||||
|
|
||||||
|
The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated
|
||||||
|
file where there is an issue with the multidata contained within it. It may come with a description like
|
||||||
|
`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)`
|
||||||
|
|
||||||
|
Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the
|
||||||
|
python objects. This means that if one of your custom class instances ends up in the multidata, the server would not
|
||||||
|
be able to load that custom class to decompress the data, which can fail either because the custom class is unknown
|
||||||
|
(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe.
|
||||||
|
|
||||||
|
Common situations where this can happen include:
|
||||||
|
* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`.
|
||||||
|
Also, consider using the `options.as_dict("option_name", "option_two")` helper.
|
||||||
|
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
|
||||||
|
make sure that you are not using your enum class for either the names or ids in these mappings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Some locations are technically possible to check with few or no items, but they'd be very tedious or frustrating. How do worlds deal with this?
|
||||||
|
|
||||||
|
Sometimes the game can be modded to skip these locations or make them less tedious. But when this issue is due to a fundamental aspect of the game, then the general answer is "soft logic" (and its subtypes like "combat logic", "money logic", etc.). For example: you can logically require that a player have several helpful items before fighting the final boss, even if a skilled player technically needs no items to beat it. Randomizer logic should describe what's *fun* rather than what's technically possible.
|
||||||
|
|
||||||
|
Concrete examples of soft logic include:
|
||||||
|
- Defeating a boss might logically require health upgrades, damage upgrades, certain weapons, etc. that aren't strictly necessary.
|
||||||
|
- Entering a high-level area might logically require access to enough other parts of the game that checking other locations should naturally get the player to the soft-required level.
|
||||||
|
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
|
||||||
|
|
||||||
|
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ flowchart LR
|
|||||||
%% Java Based Games
|
%% Java Based Games
|
||||||
subgraph Java
|
subgraph Java
|
||||||
JM[Mod with Archipelago.MultiClient.Java]
|
JM[Mod with Archipelago.MultiClient.Java]
|
||||||
STS[Slay the Spire]
|
|
||||||
JM <-- Mod the Spire --> STS
|
|
||||||
subgraph Minecraft
|
subgraph Minecraft
|
||||||
MCS[Minecraft Forge Server]
|
MCS[Minecraft Forge Server]
|
||||||
JMC[Any Java Minecraft Clients]
|
JMC[Any Java Minecraft Clients]
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ The following operations can be applied to a datastorage key
|
|||||||
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
||||||
| remove | List only: removes the first instance of `value` found in the list. |
|
| remove | List only: removes the first instance of `value` found in the list. |
|
||||||
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
||||||
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
|
| update | List or Dict: Adds the elements of `value` to the container if they weren't already present. In the case of a Dict, already present keys will have their corresponding values updated. |
|
||||||
|
|
||||||
### SetNotify
|
### SetNotify
|
||||||
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
||||||
@@ -756,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow:
|
|||||||
### DeathLink
|
### DeathLink
|
||||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||||
|
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| time | float | Unix Time Stamp of time of death. |
|
| time | float | Unix Time Stamp of time of death. |
|
||||||
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." |
|
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, if the string is non-empty, it should contain the player name, ex. "Berserker was run over by a train." |
|
||||||
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
|
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
|
||||||
|
|||||||
@@ -352,8 +352,15 @@ template. If you set a [Schema](https://pypi.org/project/schema/) on the class w
|
|||||||
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
||||||
format.
|
format.
|
||||||
|
|
||||||
|
### OptionCounter
|
||||||
|
This is a special case of OptionDict where the dictionary values can only be integers.
|
||||||
|
It returns a [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter).
|
||||||
|
This means that if you access a key that isn't present, its value will be 0.
|
||||||
|
The upside of using an OptionCounter (instead of an OptionDict with integer values) is that an OptionCounter can be
|
||||||
|
displayed on the Options page on WebHost.
|
||||||
|
|
||||||
### ItemDict
|
### ItemDict
|
||||||
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
|
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
|
||||||
|
|
||||||
### OptionList
|
### OptionList
|
||||||
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
|||||||
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
||||||
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
||||||
|
|
||||||
|
#### Parametrization
|
||||||
|
|
||||||
|
When defining a test that needs to cover a range of inputs it is useful to parameterize (to run the same test
|
||||||
|
for multiple inputs) the base test. Some important things to consider when attempting to parametrize your test are:
|
||||||
|
|
||||||
|
* [Subtests](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests)
|
||||||
|
can be used to have parametrized assertions that show up similar to individual tests but without the overhead
|
||||||
|
of needing to instantiate multiple tests; however, subtests can not be multithreaded and do not have individual
|
||||||
|
timing data, so they are not suitable for slow tests.
|
||||||
|
|
||||||
|
* Archipelago's tests are test-runner-agnostic. That means tests are not allowed to use e.g. `@pytest.mark.parametrize`.
|
||||||
|
Instead, we define our own parametrization helpers in [test.param](/test/param.py).
|
||||||
|
|
||||||
|
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
||||||
|
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
||||||
|
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
|
||||||
|
or setting `WorldTestBase.run_default_tests` to False.
|
||||||
|
|
||||||
|
#### Performance Considerations
|
||||||
|
|
||||||
|
Archipelago is big enough that the runtime of unittests can have an impact on productivity.
|
||||||
|
|
||||||
|
Individual tests should take less than a second, so they can be properly multithreaded.
|
||||||
|
|
||||||
|
Ideally, thorough tests are directed at actual code/functionality. Do not just create and/or fill a ton of individual
|
||||||
|
Multiworlds that spend most of the test time outside what you actually want to test.
|
||||||
|
|
||||||
|
Consider generating/validating "random" games as part of your APWorld release workflow rather than having that be part
|
||||||
|
of continuous integration, and add minimal reproducers to the "normal" tests for problems that were found.
|
||||||
|
You can use [@unittest.skipIf](https://docs.python.org/3/library/unittest.html#unittest.skipIf) with an environment
|
||||||
|
variable to keep all the benefits of the test framework while not running the marked tests by default.
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
#### Using Pycharm
|
#### Using Pycharm
|
||||||
@@ -100,3 +132,11 @@ next to the run and debug buttons.
|
|||||||
#### Running Tests without Pycharm
|
#### Running Tests without Pycharm
|
||||||
|
|
||||||
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
||||||
|
|
||||||
|
#### Running Tests Multithreaded
|
||||||
|
|
||||||
|
pytest can run multiple test runners in parallel with the pytest-xdist extension.
|
||||||
|
|
||||||
|
Install with `pip install pytest-xdist`.
|
||||||
|
|
||||||
|
Run with `pytest -n12` to spawn 12 process that each run 1/12th of the tests.
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ from .items import is_progression # this is just a dummy
|
|||||||
|
|
||||||
|
|
||||||
def create_item(self, item: str) -> MyGameItem:
|
def create_item(self, item: str) -> MyGameItem:
|
||||||
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
# this is called when AP wants to create an item by name (for plando, start inventory, item links) or when you call it from your own code
|
||||||
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||||
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||||
|
|
||||||
@@ -606,8 +606,8 @@ from .items import get_item_type
|
|||||||
|
|
||||||
def set_rules(self) -> None:
|
def set_rules(self) -> None:
|
||||||
# For some worlds this step can be omitted if either a Logic mixin
|
# For some worlds this step can be omitted if either a Logic mixin
|
||||||
# (see below) is used, it's easier to apply the rules from data during
|
# (see below) is used or it's easier to apply the rules from data during
|
||||||
# location generation or everything is in generate_basic
|
# location generation
|
||||||
|
|
||||||
# set a simple rule for an region
|
# set a simple rule for an region
|
||||||
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
set_rule(self.multiworld.get_entrance("Boss Door", self.player),
|
||||||
|
|||||||
@@ -50,13 +50,15 @@ class EntranceLookup:
|
|||||||
_random: random.Random
|
_random: random.Random
|
||||||
_expands_graph_cache: dict[Entrance, bool]
|
_expands_graph_cache: dict[Entrance, bool]
|
||||||
_coupled: bool
|
_coupled: bool
|
||||||
|
_usable_exits: set[Entrance]
|
||||||
|
|
||||||
def __init__(self, rng: random.Random, coupled: bool):
|
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
|
||||||
self.dead_ends = EntranceLookup.GroupLookup()
|
self.dead_ends = EntranceLookup.GroupLookup()
|
||||||
self.others = EntranceLookup.GroupLookup()
|
self.others = EntranceLookup.GroupLookup()
|
||||||
self._random = rng
|
self._random = rng
|
||||||
self._expands_graph_cache = {}
|
self._expands_graph_cache = {}
|
||||||
self._coupled = coupled
|
self._coupled = coupled
|
||||||
|
self._usable_exits = usable_exits
|
||||||
|
|
||||||
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
def _can_expand_graph(self, entrance: Entrance) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -95,7 +97,8 @@ class EntranceLookup:
|
|||||||
# randomizable exits which are not reverse of the incoming entrance.
|
# randomizable exits which are not reverse of the incoming entrance.
|
||||||
# uncoupled mode is an exception because in this case going back in the door you just came in could
|
# uncoupled mode is an exception because in this case going back in the door you just came in could
|
||||||
# actually lead somewhere new
|
# actually lead somewhere new
|
||||||
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name):
|
if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name)
|
||||||
|
and exit_ in self._usable_exits):
|
||||||
self._expands_graph_cache[entrance] = True
|
self._expands_graph_cache[entrance] = True
|
||||||
return True
|
return True
|
||||||
elif exit_.connected_region and exit_.connected_region not in visited:
|
elif exit_.connected_region and exit_.connected_region not in visited:
|
||||||
@@ -265,14 +268,19 @@ def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], li
|
|||||||
return { group: get_target_groups(group) for group in unique_groups }
|
return { group: get_target_groups(group) for group in unique_groups }
|
||||||
|
|
||||||
|
|
||||||
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None:
|
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None,
|
||||||
|
one_way_target_name: str | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
|
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
|
||||||
in randomize_entrances. This should be done after setting the type and group of the entrance.
|
in randomize_entrances. This should be done after setting the type and group of the entrance. Because it attempts
|
||||||
|
to meet strict entrance naming requirements for coupled mode, this function may produce unintuitive results when
|
||||||
|
called only on a single entrance; it produces eventually-correct outputs only after calling it on all entrances.
|
||||||
|
|
||||||
:param entrance: The entrance which will be disconnected in preparation for randomization.
|
:param entrance: The entrance which will be disconnected in preparation for randomization.
|
||||||
:param target_group: The group to assign to the created ER target. If not specified, the group from
|
:param target_group: The group to assign to the created ER target. If not specified, the group from
|
||||||
the original entrance will be copied.
|
the original entrance will be copied.
|
||||||
|
:param one_way_target_name: The name of the created ER target if `entrance` is one-way. This argument
|
||||||
|
is required for one-way entrances and is ignored otherwise.
|
||||||
"""
|
"""
|
||||||
child_region = entrance.connected_region
|
child_region = entrance.connected_region
|
||||||
parent_region = entrance.parent_region
|
parent_region = entrance.parent_region
|
||||||
@@ -287,8 +295,11 @@ def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int
|
|||||||
# targets in the child region will be created when the other direction edge is disconnected
|
# targets in the child region will be created when the other direction edge is disconnected
|
||||||
target = parent_region.create_er_target(entrance.name)
|
target = parent_region.create_er_target(entrance.name)
|
||||||
else:
|
else:
|
||||||
# for 1-ways, the child region needs a target and coupling/naming is not a concern
|
# for 1-ways, the child region needs a target. naming is not a concern for coupling so we
|
||||||
target = child_region.create_er_target(child_region.name)
|
# allow it to be user provided (and require it, to prevent an unhelpful assumed name in pairings)
|
||||||
|
if not one_way_target_name:
|
||||||
|
raise ValueError("Cannot disconnect a one-way entrance without a target name specified")
|
||||||
|
target = child_region.create_er_target(one_way_target_name)
|
||||||
target.randomization_type = entrance.randomization_type
|
target.randomization_type = entrance.randomization_type
|
||||||
target.randomization_group = target_group or entrance.randomization_group
|
target.randomization_group = target_group or entrance.randomization_group
|
||||||
|
|
||||||
@@ -325,7 +336,6 @@ def randomize_entrances(
|
|||||||
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
er_state = ERPlacementState(world, coupled)
|
er_state = ERPlacementState(world, coupled)
|
||||||
entrance_lookup = EntranceLookup(world.random, coupled)
|
|
||||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||||
perform_validity_check = True
|
perform_validity_check = True
|
||||||
|
|
||||||
@@ -341,6 +351,7 @@ def randomize_entrances(
|
|||||||
|
|
||||||
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
||||||
exits_set = set(exits)
|
exits_set = set(exits)
|
||||||
|
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
|
||||||
for entrance in er_targets:
|
for entrance in er_targets:
|
||||||
entrance_lookup.add(entrance)
|
entrance_lookup.add(entrance)
|
||||||
|
|
||||||
@@ -358,6 +369,34 @@ def randomize_entrances(
|
|||||||
if on_connect:
|
if on_connect:
|
||||||
on_connect(er_state, placed_exits)
|
on_connect(er_state, placed_exits)
|
||||||
|
|
||||||
|
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
|
||||||
|
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
|
||||||
|
# entirely
|
||||||
|
if len(placeable_exits) > 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# in certain stages of randomization we either expect or don't care if the search space shrinks.
|
||||||
|
# we should never speculative sweep here.
|
||||||
|
if dead_end or not require_new_exits or not perform_validity_check:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# edge case - if all dead ends have pre-placed progression or indirect connections, they are pulled forward
|
||||||
|
# into the non dead end stage. In this case, and only this case, it's possible that the last connection may
|
||||||
|
# actually be placeable in stage 1. We need to skip speculative sweep in this case because we expect the graph
|
||||||
|
# to get capped off.
|
||||||
|
|
||||||
|
# check to see if we are proposing the last placement
|
||||||
|
if not coupled:
|
||||||
|
# in uncoupled, this check is easy as there will only be one target.
|
||||||
|
is_last_placement = len(entrance_lookup) == 1
|
||||||
|
else:
|
||||||
|
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
|
||||||
|
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
|
||||||
|
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
|
||||||
|
is_last_placement = len(entrance_lookup) == desired_target_count
|
||||||
|
# if it's not the last placement, we need a sweep
|
||||||
|
return not is_last_placement
|
||||||
|
|
||||||
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||||
nonlocal perform_validity_check
|
nonlocal perform_validity_check
|
||||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
||||||
@@ -371,11 +410,9 @@ def randomize_entrances(
|
|||||||
# very last exit and check whatever exits we open up are functionally accessible.
|
# very last exit and check whatever exits we open up are functionally accessible.
|
||||||
# this requirement can be ignored on a beaten minimal, islands are no issue there.
|
# this requirement can be ignored on a beaten minimal, islands are no issue there.
|
||||||
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
|
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
|
||||||
or target_entrance.connected_region not in er_state.placed_regions)
|
or target_entrance.connected_region not in er_state.placed_regions)
|
||||||
needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check
|
|
||||||
and len(placeable_exits) == 1)
|
|
||||||
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||||
if (needs_speculative_sweep
|
if (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits)
|
||||||
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
||||||
continue
|
continue
|
||||||
do_placement(source_exit, target_entrance)
|
do_placement(source_exit, target_entrance)
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ MinVersion={#min_windows}
|
|||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}";
|
||||||
|
Name: "deletelib"; Description: "Clean existing /lib folder and subfolders including /worlds (leave checked if unsure)"; Check: ShouldShowDeleteLibTask
|
||||||
|
|
||||||
[Types]
|
[Types]
|
||||||
Name: "full"; Description: "Full installation"
|
Name: "full"; Description: "Full installation"
|
||||||
@@ -83,18 +84,8 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
|
|||||||
Type: dirifempty; Name: "{app}"
|
Type: dirifempty; Name: "{app}"
|
||||||
|
|
||||||
[InstallDelete]
|
[InstallDelete]
|
||||||
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
|
Type: files; Name: "{app}\*.exe"
|
||||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
|
||||||
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
|
|
||||||
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
|
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
|
|
||||||
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
|
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
|
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
|
|
||||||
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
|
|
||||||
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
|
|
||||||
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
|
|
||||||
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
||||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||||
#include "installdelete.iss"
|
#include "installdelete.iss"
|
||||||
@@ -221,6 +212,11 @@ Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Ar
|
|||||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||||
|
|
||||||
|
Root: HKCR; Subkey: ".apcivvi"; ValueData: "{#MyAppName}apcivvipatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "Archipelago Civilization 6 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||||
|
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||||
@@ -256,3 +252,17 @@ begin
|
|||||||
Result := True;
|
Result := True;
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
function ShouldShowDeleteLibTask: Boolean;
|
||||||
|
begin
|
||||||
|
Result := DirExists(ExpandConstant('{app}\lib'));
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
begin
|
||||||
|
if CurStep = ssInstall then
|
||||||
|
begin
|
||||||
|
if WizardIsTaskSelected('deletelib') then
|
||||||
|
DelTree(ExpandConstant('{app}\lib'), True, True, True);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|||||||
624
kvui.py
624
kvui.py
@@ -35,8 +35,7 @@ from kivy.config import Config
|
|||||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
Config.set("kivy", "exit_on_escape", "0")
|
Config.set("kivy", "exit_on_escape", "0")
|
||||||
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
||||||
|
from kivymd.uix.divider import MDDivider
|
||||||
from kivy.app import App
|
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.core.clipboard import Clipboard
|
from kivy.core.clipboard import Clipboard
|
||||||
from kivy.core.text.markup import MarkupLabel
|
from kivy.core.text.markup import MarkupLabel
|
||||||
@@ -44,32 +43,34 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
|
|||||||
from kivy.base import ExceptionHandler, ExceptionManager
|
from kivy.base import ExceptionHandler, ExceptionManager
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
from kivy.factory import Factory
|
from kivy.factory import Factory
|
||||||
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
|
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty
|
||||||
from kivy.metrics import dp
|
from kivy.metrics import dp, sp
|
||||||
from kivy.effects.scroll import ScrollEffect
|
|
||||||
from kivy.uix.widget import Widget
|
from kivy.uix.widget import Widget
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.layout import Layout
|
from kivy.uix.layout import Layout
|
||||||
from kivy.uix.textinput import TextInput
|
|
||||||
from kivy.uix.scrollview import ScrollView
|
|
||||||
from kivy.uix.recycleview import RecycleView
|
|
||||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.floatlayout import FloatLayout
|
|
||||||
from kivy.uix.label import Label
|
|
||||||
from kivy.uix.progressbar import ProgressBar
|
|
||||||
from kivy.uix.dropdown import DropDown
|
|
||||||
from kivy.utils import escape_markup
|
from kivy.utils import escape_markup
|
||||||
from kivy.lang import Builder
|
from kivy.lang import Builder
|
||||||
from kivy.uix.recycleview.views import RecycleDataViewBehavior
|
from kivy.uix.recycleview.views import RecycleDataViewBehavior
|
||||||
from kivy.uix.behaviors import FocusBehavior
|
from kivy.uix.behaviors import FocusBehavior, ToggleButtonBehavior
|
||||||
from kivy.uix.recycleboxlayout import RecycleBoxLayout
|
from kivy.uix.recycleboxlayout import RecycleBoxLayout
|
||||||
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
|
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
|
||||||
from kivy.animation import Animation
|
from kivy.animation import Animation
|
||||||
from kivy.uix.popup import Popup
|
from kivy.uix.popup import Popup
|
||||||
from kivy.uix.dropdown import DropDown
|
|
||||||
from kivy.uix.image import AsyncImage
|
from kivy.uix.image import AsyncImage
|
||||||
|
from kivymd.app import MDApp
|
||||||
|
from kivymd.uix.gridlayout import MDGridLayout
|
||||||
|
from kivymd.uix.floatlayout import MDFloatLayout
|
||||||
|
from kivymd.uix.boxlayout import MDBoxLayout
|
||||||
|
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.menu.menu import MDDropdownTextItem
|
||||||
|
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
|
||||||
|
from kivymd.uix.button import MDButton, MDButtonText, MDButtonIcon, MDIconButton
|
||||||
|
from kivymd.uix.label import MDLabel, MDIcon
|
||||||
|
from kivymd.uix.recycleview import MDRecycleView
|
||||||
|
from kivymd.uix.textfield.textfield import MDTextField
|
||||||
|
from kivymd.uix.progressindicator import MDLinearProgressIndicator
|
||||||
|
from kivymd.uix.scrollview import MDScrollView
|
||||||
|
from kivymd.uix.tooltip import MDTooltip, MDTooltipPlain
|
||||||
|
|
||||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||||
|
|
||||||
@@ -86,6 +87,113 @@ else:
|
|||||||
remove_between_brackets = re.compile(r"\[.*?]")
|
remove_between_brackets = re.compile(r"\[.*?]")
|
||||||
|
|
||||||
|
|
||||||
|
class ThemedApp(MDApp):
|
||||||
|
def set_colors(self):
|
||||||
|
text_colors = KivyJSONtoTextParser.TextColors()
|
||||||
|
self.theme_cls.theme_style = text_colors.theme_style
|
||||||
|
self.theme_cls.primary_palette = text_colors.primary_palette
|
||||||
|
self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name
|
||||||
|
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
|
||||||
|
|
||||||
|
|
||||||
|
class ImageIcon(MDButtonIcon, AsyncImage):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.image = ApAsyncImage(**kwargs)
|
||||||
|
self.add_widget(self.image)
|
||||||
|
|
||||||
|
def add_widget(self, widget, index=0, canvas=None):
|
||||||
|
return super(MDIcon, self).add_widget(widget)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageButton(MDIconButton):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
image_args = dict()
|
||||||
|
for kwarg in ("fit_mode", "image_size", "color", "source", "texture"):
|
||||||
|
val = kwargs.pop(kwarg, "None")
|
||||||
|
if val != "None":
|
||||||
|
image_args[kwarg.replace("image_", "")] = val
|
||||||
|
super().__init__()
|
||||||
|
self.image = ApAsyncImage(**image_args)
|
||||||
|
|
||||||
|
def set_center(button, center):
|
||||||
|
self.image.center_x = self.center_x
|
||||||
|
self.image.center_y = self.center_y
|
||||||
|
|
||||||
|
self.bind(center=set_center)
|
||||||
|
self.add_widget(self.image)
|
||||||
|
|
||||||
|
def add_widget(self, widget, index=0, canvas=None):
|
||||||
|
return super(MDIcon, self).add_widget(widget)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollBox(MDScrollView):
|
||||||
|
layout: MDBoxLayout = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# thanks kivymd
|
||||||
|
class ToggleButton(MDButton, ToggleButtonBehavior):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ToggleButton, self).__init__(*args, **kwargs)
|
||||||
|
self.bind(state=self._update_bg)
|
||||||
|
|
||||||
|
def _update_bg(self, _, state: str):
|
||||||
|
if self.disabled:
|
||||||
|
return
|
||||||
|
if self.theme_bg_color == "Primary":
|
||||||
|
self.theme_bg_color = "Custom"
|
||||||
|
|
||||||
|
if state == "down":
|
||||||
|
self.md_bg_color = self.theme_cls.primaryColor
|
||||||
|
for child in self.children:
|
||||||
|
if child.theme_text_color == "Primary":
|
||||||
|
child.theme_text_color = "Custom"
|
||||||
|
if child.theme_icon_color == "Primary":
|
||||||
|
child.theme_icon_color = "Custom"
|
||||||
|
child.text_color = self.theme_cls.onPrimaryColor
|
||||||
|
child.icon_color = self.theme_cls.onPrimaryColor
|
||||||
|
else:
|
||||||
|
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
|
||||||
|
for child in self.children:
|
||||||
|
if child.theme_text_color == "Primary":
|
||||||
|
child.theme_text_color = "Custom"
|
||||||
|
if child.theme_icon_color == "Primary":
|
||||||
|
child.theme_icon_color = "Custom"
|
||||||
|
child.text_color = self.theme_cls.primaryColor
|
||||||
|
child.icon_color = self.theme_cls.primaryColor
|
||||||
|
|
||||||
|
|
||||||
|
# thanks kivymd
|
||||||
|
class ResizableTextField(MDTextField):
|
||||||
|
"""
|
||||||
|
Resizable MDTextField that manually overrides the builtin sizing.
|
||||||
|
|
||||||
|
Note that in order to use this, the sizing must be specified from within a .kv rule.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# cursed rules override
|
||||||
|
rules = Builder.match(self)
|
||||||
|
textfield = next((rule for rule in rules if rule.name == f"<MDTextField>"), None)
|
||||||
|
if textfield:
|
||||||
|
subclasses = rules[rules.index(textfield) + 1:]
|
||||||
|
for subclass in subclasses:
|
||||||
|
height_rule = subclass.properties.get("height", None)
|
||||||
|
if height_rule:
|
||||||
|
height_rule.ignore_prev = True
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def on_release(self: MDButton, *args):
|
||||||
|
super(MDButton, self).on_release(args)
|
||||||
|
self.on_leave()
|
||||||
|
|
||||||
|
|
||||||
|
MDButton.on_release = on_release
|
||||||
|
|
||||||
|
|
||||||
# I was surprised to find this didn't already exist in kivy :(
|
# I was surprised to find this didn't already exist in kivy :(
|
||||||
class HoverBehavior(object):
|
class HoverBehavior(object):
|
||||||
"""originally from https://stackoverflow.com/a/605348110"""
|
"""originally from https://stackoverflow.com/a/605348110"""
|
||||||
@@ -125,7 +233,7 @@ class HoverBehavior(object):
|
|||||||
Factory.register("HoverBehavior", HoverBehavior)
|
Factory.register("HoverBehavior", HoverBehavior)
|
||||||
|
|
||||||
|
|
||||||
class ToolTip(Label):
|
class ToolTip(MDTooltipPlain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -133,49 +241,30 @@ class ServerToolTip(ToolTip):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ScrollBox(ScrollView):
|
class HovererableLabel(HoverBehavior, MDLabel):
|
||||||
layout: BoxLayout
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.layout = BoxLayout(size_hint_y=None)
|
|
||||||
self.layout.bind(minimum_height=self.layout.setter("height"))
|
|
||||||
self.add_widget(self.layout)
|
|
||||||
self.effect_cls = ScrollEffect
|
|
||||||
self.bar_width = dp(12)
|
|
||||||
self.scroll_type = ["content", "bars"]
|
|
||||||
|
|
||||||
|
|
||||||
class HovererableLabel(HoverBehavior, Label):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TooltipLabel(HovererableLabel):
|
class TooltipLabel(HovererableLabel, MDTooltip):
|
||||||
tooltip = None
|
tooltip_display_delay = 0.1
|
||||||
|
|
||||||
def create_tooltip(self, text, x, y):
|
def create_tooltip(self, text, x, y):
|
||||||
text = text.replace("<br>", "\n").replace("&", "&").replace("&bl;", "[").replace("&br;", "]")
|
text = text.replace("<br>", "\n").replace("&", "&").replace("&bl;", "[").replace("&br;", "]")
|
||||||
if self.tooltip:
|
|
||||||
# update
|
|
||||||
self.tooltip.children[0].text = text
|
|
||||||
else:
|
|
||||||
self.tooltip = FloatLayout()
|
|
||||||
tooltip_label = ToolTip(text=text)
|
|
||||||
self.tooltip.add_widget(tooltip_label)
|
|
||||||
fade_in_animation.start(self.tooltip)
|
|
||||||
App.get_running_app().root.add_widget(self.tooltip)
|
|
||||||
|
|
||||||
# handle left-side boundary to not render off-screen
|
|
||||||
x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2)
|
|
||||||
|
|
||||||
# position float layout
|
# position float layout
|
||||||
self.tooltip.x = x - self.tooltip.width / 2
|
center_x, center_y = self.to_window(self.center_x, self.center_y)
|
||||||
self.tooltip.y = y - self.tooltip.height / 2 + 48
|
self.shift_y = y - center_y
|
||||||
|
shift_x = center_x - x
|
||||||
|
if shift_x > 0:
|
||||||
|
self.shift_left = shift_x
|
||||||
|
else:
|
||||||
|
self.shift_right = shift_x
|
||||||
|
|
||||||
def remove_tooltip(self):
|
if self._tooltip:
|
||||||
if self.tooltip:
|
# update
|
||||||
App.get_running_app().root.remove_widget(self.tooltip)
|
self._tooltip.text = text
|
||||||
self.tooltip = None
|
else:
|
||||||
|
self._tooltip = ToolTip(text=text, pos_hint={})
|
||||||
|
self.display_tooltip()
|
||||||
|
|
||||||
def on_mouse_pos(self, window, pos):
|
def on_mouse_pos(self, window, pos):
|
||||||
if not self.get_root_window():
|
if not self.get_root_window():
|
||||||
@@ -202,26 +291,30 @@ class TooltipLabel(HovererableLabel):
|
|||||||
|
|
||||||
def on_leave(self):
|
def on_leave(self):
|
||||||
self.remove_tooltip()
|
self.remove_tooltip()
|
||||||
|
self._tooltip = None
|
||||||
|
|
||||||
|
|
||||||
class ServerLabel(HovererableLabel):
|
class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
|
||||||
|
tooltip_display_delay = 0.1
|
||||||
|
text: str = StringProperty("Server:")
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(HovererableLabel, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.layout = FloatLayout()
|
self.add_widget(MDIcon(icon="information", font_size=sp(15)))
|
||||||
self.popuplabel = ServerToolTip(text="Test")
|
self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5},
|
||||||
self.layout.add_widget(self.popuplabel)
|
font_size=sp(15)))
|
||||||
|
self._tooltip = ServerToolTip(text="Test")
|
||||||
|
|
||||||
def on_enter(self):
|
def on_enter(self):
|
||||||
self.popuplabel.text = self.get_text()
|
self._tooltip.text = self.get_text()
|
||||||
App.get_running_app().root.add_widget(self.layout)
|
self.display_tooltip()
|
||||||
fade_in_animation.start(self.layout)
|
|
||||||
|
|
||||||
def on_leave(self):
|
def on_leave(self):
|
||||||
App.get_running_app().root.remove_widget(self.layout)
|
self.animation_tooltip_dismiss()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ctx(self) -> context_type:
|
def ctx(self) -> context_type:
|
||||||
return App.get_running_app().ctx
|
return MDApp.get_running_app().ctx
|
||||||
|
|
||||||
def get_text(self):
|
def get_text(self):
|
||||||
if self.ctx.server:
|
if self.ctx.server:
|
||||||
@@ -262,11 +355,11 @@ class ServerLabel(HovererableLabel):
|
|||||||
return "No current server connection. \nPlease connect to an Archipelago server."
|
return "No current server connection. \nPlease connect to an Archipelago server."
|
||||||
|
|
||||||
|
|
||||||
class MainLayout(GridLayout):
|
class MainLayout(MDGridLayout):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ContainerLayout(FloatLayout):
|
class ContainerLayout(MDFloatLayout):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -286,6 +379,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
|||||||
return super(SelectableLabel, self).refresh_view_attrs(
|
return super(SelectableLabel, self).refresh_view_attrs(
|
||||||
rv, index, data)
|
rv, index, data)
|
||||||
|
|
||||||
|
def on_size(self, instance_label, size: list) -> None:
|
||||||
|
super().on_size(instance_label, size)
|
||||||
|
if self.parent:
|
||||||
|
self.width = self.parent.width
|
||||||
|
|
||||||
def on_touch_down(self, touch):
|
def on_touch_down(self, touch):
|
||||||
""" Add selection on touch down """
|
""" Add selection on touch down """
|
||||||
if super(SelectableLabel, self).on_touch_down(touch):
|
if super(SelectableLabel, self).on_touch_down(touch):
|
||||||
@@ -296,10 +394,10 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
|||||||
else:
|
else:
|
||||||
# Not a fan of the following few lines, but they work.
|
# Not a fan of the following few lines, but they work.
|
||||||
temp = MarkupLabel(text=self.text).markup
|
temp = MarkupLabel(text=self.text).markup
|
||||||
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
text = "".join(part for part in temp if not part.startswith("["))
|
||||||
cmdinput = App.get_running_app().textinput
|
cmdinput = MDApp.get_running_app().textinput
|
||||||
if not cmdinput.text:
|
if not cmdinput.text:
|
||||||
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
|
input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command)
|
||||||
if input_text is not None:
|
if input_text is not None:
|
||||||
cmdinput.text = input_text
|
cmdinput.text = input_text
|
||||||
|
|
||||||
@@ -310,32 +408,118 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
|||||||
""" Respond to the selection of items in the view. """
|
""" Respond to the selection of items in the view. """
|
||||||
self.selected = is_selected
|
self.selected = is_selected
|
||||||
|
|
||||||
|
|
||||||
class AutocompleteHintInput(TextInput):
|
class MarkupDropdownTextItem(MDDropdownTextItem):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
for child in self.children:
|
||||||
|
if child.__class__ == MDLabel:
|
||||||
|
child.markup = True
|
||||||
|
# Currently, this only lets us do markup on text that does not have any icons
|
||||||
|
# Create new TextItems as needed
|
||||||
|
|
||||||
|
|
||||||
|
class MarkupDropdown(MDDropdownMenu):
|
||||||
|
def on_items(self, instance, value: list) -> None:
|
||||||
|
"""
|
||||||
|
The method sets the class that will be used to create the menu item.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = []
|
||||||
|
viewclass = "MarkupDropdownTextItem"
|
||||||
|
|
||||||
|
for data in value:
|
||||||
|
if "viewclass" not in data:
|
||||||
|
if (
|
||||||
|
"leading_icon" not in data
|
||||||
|
and "trailing_icon" not in data
|
||||||
|
and "trailing_text" not in data
|
||||||
|
):
|
||||||
|
viewclass = "MarkupDropdownTextItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" in data
|
||||||
|
and "trailing_icon" not in data
|
||||||
|
and "trailing_text" not in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownLeadingIconItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" not in data
|
||||||
|
and "trailing_icon" in data
|
||||||
|
and "trailing_text" not in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownTrailingIconItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" not in data
|
||||||
|
and "trailing_icon" in data
|
||||||
|
and "trailing_text" in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownTrailingIconTextItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" in data
|
||||||
|
and "trailing_icon" in data
|
||||||
|
and "trailing_text" in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownLeadingTrailingIconTextItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" in data
|
||||||
|
and "trailing_icon" in data
|
||||||
|
and "trailing_text" not in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownLeadingTrailingIconItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" not in data
|
||||||
|
and "trailing_icon" not in data
|
||||||
|
and "trailing_text" in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownTrailingTextItem"
|
||||||
|
elif (
|
||||||
|
"leading_icon" in data
|
||||||
|
and "trailing_icon" not in data
|
||||||
|
and "trailing_text" in data
|
||||||
|
):
|
||||||
|
viewclass = "MDDropdownLeadingIconTrailingTextItem"
|
||||||
|
|
||||||
|
data["viewclass"] = viewclass
|
||||||
|
|
||||||
|
if "height" not in data:
|
||||||
|
data["height"] = dp(48)
|
||||||
|
|
||||||
|
items.append(data)
|
||||||
|
|
||||||
|
self._items = items
|
||||||
|
# Update items in view
|
||||||
|
if hasattr(self, "menu"):
|
||||||
|
self.menu.data = self._items
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteHintInput(ResizableTextField):
|
||||||
min_chars = NumericProperty(3)
|
min_chars = NumericProperty(3)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.dropdown = DropDown()
|
self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width)
|
||||||
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
|
|
||||||
self.bind(on_text_validate=self.on_message)
|
self.bind(on_text_validate=self.on_message)
|
||||||
|
self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x))
|
||||||
|
|
||||||
def on_message(self, instance):
|
def on_message(self, instance):
|
||||||
App.get_running_app().commandprocessor("!hint "+instance.text)
|
MDApp.get_running_app().commandprocessor("!hint "+instance.text)
|
||||||
|
|
||||||
def on_text(self, instance, value):
|
def on_text(self, instance, value):
|
||||||
if len(value) >= self.min_chars:
|
if len(value) >= self.min_chars:
|
||||||
self.dropdown.clear_widgets()
|
self.dropdown.items.clear()
|
||||||
ctx: context_type = App.get_running_app().ctx
|
ctx: context_type = MDApp.get_running_app().ctx
|
||||||
if not ctx.game:
|
if not ctx.game:
|
||||||
return
|
return
|
||||||
item_names = ctx.item_names._game_store[ctx.game].values()
|
item_names = ctx.item_names._game_store[ctx.game].values()
|
||||||
|
|
||||||
def on_press(button: Button):
|
def on_press(text):
|
||||||
split_text = MarkupLabel(text=button.text).markup
|
split_text = MarkupLabel(text=text).markup
|
||||||
return self.dropdown.select("".join(text_frag for text_frag in split_text
|
self.set_text(self, "".join(text_frag for text_frag in split_text
|
||||||
if not text_frag.startswith("[")))
|
if not text_frag.startswith("[")))
|
||||||
|
self.dropdown.dismiss()
|
||||||
|
self.focus = True
|
||||||
|
|
||||||
lowered = value.lower()
|
lowered = value.lower()
|
||||||
for item_name in item_names:
|
for item_name in item_names:
|
||||||
try:
|
try:
|
||||||
@@ -345,20 +529,29 @@ class AutocompleteHintInput(TextInput):
|
|||||||
else:
|
else:
|
||||||
text = escape_markup(item_name)
|
text = escape_markup(item_name)
|
||||||
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
|
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
|
||||||
btn = Button(text=text, size_hint_y=None, height=dp(30), markup=True)
|
self.dropdown.items.append({
|
||||||
btn.bind(on_release=on_press)
|
"text": text,
|
||||||
self.dropdown.add_widget(btn)
|
"on_release": lambda txt=text: on_press(txt),
|
||||||
if not self.dropdown.attach_to:
|
"markup": True
|
||||||
self.dropdown.open(self)
|
})
|
||||||
|
if not self.dropdown.parent:
|
||||||
|
self.dropdown.open()
|
||||||
else:
|
else:
|
||||||
self.dropdown.dismiss()
|
self.dropdown.dismiss()
|
||||||
|
|
||||||
|
|
||||||
class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
status_icons = {
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "information",
|
||||||
|
HintStatus.HINT_PRIORITY: "exclamation-thick",
|
||||||
|
HintStatus.HINT_AVOID: "alert"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
|
||||||
selected = BooleanProperty(False)
|
selected = BooleanProperty(False)
|
||||||
striped = BooleanProperty(False)
|
striped = BooleanProperty(False)
|
||||||
index = None
|
index = None
|
||||||
dropdown: DropDown
|
dropdown: MDDropdownMenu
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(HintLabel, self).__init__()
|
super(HintLabel, self).__init__()
|
||||||
@@ -369,29 +562,28 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
self.entrance_text = ""
|
self.entrance_text = ""
|
||||||
self.status_text = ""
|
self.status_text = ""
|
||||||
self.hint = {}
|
self.hint = {}
|
||||||
for child in self.children:
|
|
||||||
child.bind(texture_size=self.set_height)
|
|
||||||
|
|
||||||
|
ctx = MDApp.get_running_app().ctx
|
||||||
|
menu_items = []
|
||||||
|
|
||||||
ctx = App.get_running_app().ctx
|
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
|
||||||
self.dropdown = DropDown()
|
name = status_names[status]
|
||||||
|
status_button = MDDropDownItem(MDDropDownItemText(text=name), size_hint_y=None, height=dp(50))
|
||||||
|
status_button.status = status
|
||||||
|
menu_items.append({
|
||||||
|
"text": name,
|
||||||
|
"leading_icon": status_icons[status],
|
||||||
|
"on_release": lambda x=status: select(self, x)
|
||||||
|
})
|
||||||
|
|
||||||
def set_value(button):
|
self.dropdown = MDDropdownMenu(caller=self.ids["status"], items=menu_items)
|
||||||
self.dropdown.select(button.status)
|
|
||||||
|
|
||||||
def select(instance, data):
|
def select(instance, data):
|
||||||
ctx.update_hint(self.hint["location"],
|
ctx.update_hint(self.hint["location"],
|
||||||
self.hint["finding_player"],
|
self.hint["finding_player"],
|
||||||
data)
|
data)
|
||||||
|
|
||||||
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
|
self.dropdown.bind(on_release=self.dropdown.dismiss)
|
||||||
name = status_names[status]
|
|
||||||
status_button = Button(text=name, size_hint_y=None, height=dp(50))
|
|
||||||
status_button.status = status
|
|
||||||
status_button.bind(on_release=set_value)
|
|
||||||
self.dropdown.add_widget(status_button)
|
|
||||||
|
|
||||||
self.dropdown.bind(on_select=select)
|
|
||||||
|
|
||||||
def set_height(self, instance, value):
|
def set_height(self, instance, value):
|
||||||
self.height = max([child.texture_size[1] for child in self.children])
|
self.height = max([child.texture_size[1] for child in self.children])
|
||||||
@@ -406,7 +598,6 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
self.entrance_text = data["entrance"]["text"]
|
self.entrance_text = data["entrance"]["text"]
|
||||||
self.status_text = data["status"]["text"]
|
self.status_text = data["status"]["text"]
|
||||||
self.hint = data["status"]["hint"]
|
self.hint = data["status"]["hint"]
|
||||||
self.height = self.minimum_height
|
|
||||||
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
|
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
|
||||||
|
|
||||||
def on_touch_down(self, touch):
|
def on_touch_down(self, touch):
|
||||||
@@ -419,10 +610,10 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
if status_label.collide_point(*touch.pos):
|
if status_label.collide_point(*touch.pos):
|
||||||
if self.hint["status"] == HintStatus.HINT_FOUND:
|
if self.hint["status"] == HintStatus.HINT_FOUND:
|
||||||
return
|
return
|
||||||
ctx = App.get_running_app().ctx
|
ctx = MDApp.get_running_app().ctx
|
||||||
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
|
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
|
||||||
# open a dropdown
|
# open a dropdown
|
||||||
self.dropdown.open(self.ids["status"])
|
self.dropdown.open()
|
||||||
elif self.selected:
|
elif self.selected:
|
||||||
self.parent.clear_selection()
|
self.parent.clear_selection()
|
||||||
else:
|
else:
|
||||||
@@ -431,8 +622,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
if self.entrance_text != "Vanilla"
|
if self.entrance_text != "Vanilla"
|
||||||
else "", ". (", self.status_text.lower(), ")"))
|
else "", ". (", self.status_text.lower(), ")"))
|
||||||
temp = MarkupLabel(text).markup
|
temp = MarkupLabel(text).markup
|
||||||
text = "".join(
|
text = "".join(part for part in temp if not part.startswith("["))
|
||||||
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
|
||||||
Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
||||||
return self.parent.select_with_touch(self.index, touch)
|
return self.parent.select_with_touch(self.index, touch)
|
||||||
else:
|
else:
|
||||||
@@ -455,7 +645,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
else:
|
else:
|
||||||
parent.sort_key = key
|
parent.sort_key = key
|
||||||
parent.reversed = False
|
parent.reversed = False
|
||||||
App.get_running_app().update_hints()
|
MDApp.get_running_app().update_hints()
|
||||||
|
|
||||||
def apply_selection(self, rv, index, is_selected):
|
def apply_selection(self, rv, index, is_selected):
|
||||||
""" Respond to the selection of items in the view. """
|
""" Respond to the selection of items in the view. """
|
||||||
@@ -463,7 +653,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
self.selected = is_selected
|
self.selected = is_selected
|
||||||
|
|
||||||
|
|
||||||
class ConnectBarTextInput(TextInput):
|
class ConnectBarTextInput(ResizableTextField):
|
||||||
def insert_text(self, substring, from_undo=False):
|
def insert_text(self, substring, from_undo=False):
|
||||||
s = substring.replace("\n", "").replace("\r", "")
|
s = substring.replace("\n", "").replace("\r", "")
|
||||||
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
||||||
@@ -473,14 +663,14 @@ def is_command_input(string: str) -> bool:
|
|||||||
return len(string) > 0 and string[0] in "/!"
|
return len(string) > 0 and string[0] in "/!"
|
||||||
|
|
||||||
|
|
||||||
class CommandPromptTextInput(TextInput):
|
class CommandPromptTextInput(ResizableTextField):
|
||||||
MAXIMUM_HISTORY_MESSAGES = 50
|
MAXIMUM_HISTORY_MESSAGES = 50
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._command_history_index = -1
|
self._command_history_index = -1
|
||||||
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
|
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
|
||||||
|
|
||||||
def update_history(self, new_entry: str) -> None:
|
def update_history(self, new_entry: str) -> None:
|
||||||
self._command_history_index = -1
|
self._command_history_index = -1
|
||||||
if is_command_input(new_entry):
|
if is_command_input(new_entry):
|
||||||
@@ -507,7 +697,7 @@ class CommandPromptTextInput(TextInput):
|
|||||||
self._change_to_history_text_if_available(self._command_history_index - 1)
|
self._change_to_history_text_if_available(self._command_history_index - 1)
|
||||||
return True
|
return True
|
||||||
return super().keyboard_on_key_down(window, keycode, text, modifiers)
|
return super().keyboard_on_key_down(window, keycode, text, modifiers)
|
||||||
|
|
||||||
def _change_to_history_text_if_available(self, new_index: int) -> None:
|
def _change_to_history_text_if_available(self, new_index: int) -> None:
|
||||||
if new_index < -1:
|
if new_index < -1:
|
||||||
return
|
return
|
||||||
@@ -521,32 +711,96 @@ class CommandPromptTextInput(TextInput):
|
|||||||
|
|
||||||
|
|
||||||
class MessageBox(Popup):
|
class MessageBox(Popup):
|
||||||
class MessageBoxLabel(Label):
|
class MessageBoxLabel(MDLabel):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._label.refresh()
|
self._label.refresh()
|
||||||
self.size = self._label.texture.size
|
|
||||||
if self.width + 50 > Window.width:
|
|
||||||
self.text_size[0] = Window.width - 50
|
|
||||||
self._label.refresh()
|
|
||||||
self.size = self._label.texture.size
|
|
||||||
|
|
||||||
def __init__(self, title, text, error=False, **kwargs):
|
def __init__(self, title, text, error=False, **kwargs):
|
||||||
label = MessageBox.MessageBoxLabel(text=text)
|
label = MessageBox.MessageBoxLabel(text=text)
|
||||||
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
||||||
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40),
|
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
|
||||||
separator_color=separator_color, **kwargs)
|
separator_color=separator_color, **kwargs)
|
||||||
self.height += max(0, label.height - 18)
|
self.height += max(0, label.height - 18)
|
||||||
|
|
||||||
|
|
||||||
class GameManager(App):
|
class ClientTabs(MDTabsSecondary):
|
||||||
|
carousel: MDTabsCarousel
|
||||||
|
lock_swiping = True
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
|
||||||
|
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
|
||||||
|
self.size_hint_y = 1
|
||||||
|
|
||||||
|
def _check_panel_height(self, *args):
|
||||||
|
self.ids.tab_scroll.height = dp(38)
|
||||||
|
|
||||||
|
def update_indicator(
|
||||||
|
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
|
||||||
|
) -> None:
|
||||||
|
def update_indicator(*args):
|
||||||
|
indicator_pos = (0, 0)
|
||||||
|
indicator_size = (0, 0)
|
||||||
|
|
||||||
|
item_text_object = self._get_tab_item_text_icon_object()
|
||||||
|
|
||||||
|
if item_text_object:
|
||||||
|
indicator_pos = (
|
||||||
|
instance.x + dp(12),
|
||||||
|
self.indicator.pos[1]
|
||||||
|
if not self._tabs_carousel
|
||||||
|
else self._tabs_carousel.height,
|
||||||
|
)
|
||||||
|
indicator_size = (
|
||||||
|
instance.width - dp(24),
|
||||||
|
self.indicator_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
Animation(
|
||||||
|
pos=indicator_pos,
|
||||||
|
size=indicator_size,
|
||||||
|
d=0 if not self.indicator_anim else self.indicator_duration,
|
||||||
|
t=self.indicator_transition,
|
||||||
|
).start(self.indicator)
|
||||||
|
|
||||||
|
if not instance:
|
||||||
|
self.indicator.pos = (x, self.indicator.pos[1])
|
||||||
|
self.indicator.size = (w, self.indicator_height)
|
||||||
|
else:
|
||||||
|
Clock.schedule_once(update_indicator)
|
||||||
|
|
||||||
|
def remove_tab(self, tab, content=None):
|
||||||
|
if content is None:
|
||||||
|
content = tab.content
|
||||||
|
self.ids.container.remove_widget(tab)
|
||||||
|
self.carousel.remove_widget(content)
|
||||||
|
self.on_size(self, self.size)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandButton(MDButton, MDTooltip):
|
||||||
|
def __init__(self, *args, manager: "GameManager", **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.manager = manager
|
||||||
|
self._tooltip = ToolTip(text="Test")
|
||||||
|
|
||||||
|
def on_enter(self):
|
||||||
|
self._tooltip.text = self.manager.commandprocessor.get_help_text()
|
||||||
|
self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here
|
||||||
|
self.display_tooltip()
|
||||||
|
|
||||||
|
def on_leave(self):
|
||||||
|
self.animation_tooltip_dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
class GameManager(ThemedApp):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
("Client", "Archipelago"),
|
("Client", "Archipelago"),
|
||||||
]
|
]
|
||||||
base_title: str = "Archipelago Client"
|
base_title: str = "Archipelago Client"
|
||||||
last_autofillable_command: str
|
last_autofillable_command: str
|
||||||
|
|
||||||
main_area_container: GridLayout
|
main_area_container: MDGridLayout
|
||||||
""" subclasses can add more columns beside the tabs """
|
""" subclasses can add more columns beside the tabs """
|
||||||
|
|
||||||
def __init__(self, ctx: context_type):
|
def __init__(self, ctx: context_type):
|
||||||
@@ -581,45 +835,58 @@ class GameManager(App):
|
|||||||
return max(1, len(self.tabs.tab_list))
|
return max(1, len(self.tabs.tab_list))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
def on_start(*args):
|
||||||
|
self.root.md_bg_color = self.theme_cls.backgroundColor
|
||||||
|
super().on_start()
|
||||||
|
Clock.schedule_once(on_start)
|
||||||
|
|
||||||
def build(self) -> Layout:
|
def build(self) -> Layout:
|
||||||
|
self.set_colors()
|
||||||
self.container = ContainerLayout()
|
self.container = ContainerLayout()
|
||||||
|
|
||||||
self.grid = MainLayout()
|
self.grid = MainLayout()
|
||||||
self.grid.cols = 1
|
self.grid.cols = 1
|
||||||
self.connect_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
|
self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40),
|
||||||
|
spacing=5, padding=(5, 10))
|
||||||
# top part
|
# top part
|
||||||
server_label = ServerLabel()
|
server_label = ServerLabel(width=dp(75))
|
||||||
self.connect_layout.add_widget(server_label)
|
self.connect_layout.add_widget(server_label)
|
||||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
|
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
|
||||||
size_hint_y=None,
|
pos_hint={"center_x": 0.5, "center_y": 0.5})
|
||||||
height=dp(30), multiline=False, write_tab=False)
|
|
||||||
|
|
||||||
def connect_bar_validate(sender):
|
def connect_bar_validate(sender):
|
||||||
if not self.ctx.server:
|
if not self.ctx.server:
|
||||||
self.connect_button_action(sender)
|
self.connect_button_action(sender)
|
||||||
|
|
||||||
|
self.server_connect_bar.height = dp(30)
|
||||||
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
|
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
|
||||||
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=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None)
|
self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)),
|
||||||
|
size_hint_x=None, size_hint_y=None, radius=5, pos_hint={"center_y": 0.55})
|
||||||
self.server_connect_button.bind(on_press=self.connect_button_action)
|
self.server_connect_button.bind(on_press=self.connect_button_action)
|
||||||
|
self.server_connect_button.height = self.server_connect_bar.height
|
||||||
self.connect_layout.add_widget(self.server_connect_button)
|
self.connect_layout.add_widget(self.server_connect_button)
|
||||||
self.grid.add_widget(self.connect_layout)
|
self.grid.add_widget(self.connect_layout)
|
||||||
self.progressbar = ProgressBar(size_hint_y=None, height=3)
|
self.progressbar = MDLinearProgressIndicator(size_hint_y=None, height=3)
|
||||||
self.grid.add_widget(self.progressbar)
|
self.grid.add_widget(self.progressbar)
|
||||||
|
|
||||||
# middle part
|
# middle part
|
||||||
self.tabs = TabbedPanel(size_hint_y=1)
|
self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
|
||||||
self.tabs.default_tab_text = "All"
|
self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
|
||||||
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
|
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
|
||||||
for logger_name, name in
|
for logger_name, name in
|
||||||
self.logging_pairs))
|
self.logging_pairs))
|
||||||
|
self.tabs.carousel.add_widget(self.tabs.default_tab_content)
|
||||||
|
|
||||||
for logger_name, display_name in self.logging_pairs:
|
for logger_name, display_name in self.logging_pairs:
|
||||||
bridge_logger = logging.getLogger(logger_name)
|
bridge_logger = logging.getLogger(logger_name)
|
||||||
panel = TabbedPanelItem(text=display_name)
|
self.log_panels[display_name] = UILog(bridge_logger)
|
||||||
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
|
|
||||||
if len(self.logging_pairs) > 1:
|
if len(self.logging_pairs) > 1:
|
||||||
|
panel = MDTabsItem(MDTabsItemText(text=display_name))
|
||||||
|
panel.content = self.log_panels[display_name]
|
||||||
# show Archipelago tab if other logging is present
|
# show Archipelago tab if other logging is present
|
||||||
|
self.tabs.carousel.add_widget(panel.content)
|
||||||
self.tabs.add_widget(panel)
|
self.tabs.add_widget(panel)
|
||||||
|
|
||||||
hint_panel = self.add_client_tab("Hints", HintLayout())
|
hint_panel = self.add_client_tab("Hints", HintLayout())
|
||||||
@@ -627,21 +894,21 @@ class GameManager(App):
|
|||||||
self.log_panels["Hints"] = hint_panel.content
|
self.log_panels["Hints"] = hint_panel.content
|
||||||
hint_panel.content.add_widget(self.hint_log)
|
hint_panel.content.add_widget(self.hint_log)
|
||||||
|
|
||||||
if len(self.logging_pairs) == 1:
|
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
|
||||||
self.tabs.default_tab_text = "Archipelago"
|
|
||||||
|
|
||||||
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
|
|
||||||
self.main_area_container.add_widget(self.tabs)
|
self.main_area_container.add_widget(self.tabs)
|
||||||
|
|
||||||
self.grid.add_widget(self.main_area_container)
|
self.grid.add_widget(self.main_area_container)
|
||||||
|
|
||||||
# bottom part
|
# bottom part
|
||||||
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
|
bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10))
|
||||||
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
|
info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5,
|
||||||
|
style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None,
|
||||||
|
pos_hint={"center_y": 0.575})
|
||||||
info_button.bind(on_release=self.command_button_action)
|
info_button.bind(on_release=self.command_button_action)
|
||||||
bottom_layout.add_widget(info_button)
|
bottom_layout.add_widget(info_button)
|
||||||
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
self.textinput = CommandPromptTextInput(size_hint_y=None, multiline=False, write_tab=False)
|
||||||
self.textinput.bind(on_text_validate=self.on_message)
|
self.textinput.bind(on_text_validate=self.on_message)
|
||||||
|
info_button.height = self.textinput.height
|
||||||
self.textinput.text_validate_unfocus = False
|
self.textinput.text_validate_unfocus = False
|
||||||
bottom_layout.add_widget(self.textinput)
|
bottom_layout.add_widget(self.textinput)
|
||||||
self.grid.add_widget(bottom_layout)
|
self.grid.add_widget(bottom_layout)
|
||||||
@@ -657,29 +924,43 @@ class GameManager(App):
|
|||||||
self.server_connect_bar.focus = True
|
self.server_connect_bar.focus = True
|
||||||
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
|
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
|
||||||
|
|
||||||
|
# Uncomment to enable the kivy live editor console
|
||||||
|
# Press Ctrl-E (with numlock/capslock) disabled to open
|
||||||
|
# from kivy.core.window import Window
|
||||||
|
# from kivy.modules import console
|
||||||
|
# console.create_console(Window, self.container)
|
||||||
|
|
||||||
return self.container
|
return self.container
|
||||||
|
|
||||||
def add_client_tab(self, title: str, content: Widget) -> Widget:
|
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
|
||||||
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
|
||||||
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||||
new_tab = TabbedPanelItem(text=title)
|
new_tab = MDTabsItem(MDTabsItemText(text=title))
|
||||||
new_tab.content = content
|
new_tab.content = content
|
||||||
self.tabs.add_widget(new_tab)
|
if -1 < index <= len(self.tabs.carousel.slides):
|
||||||
|
new_tab.bind(on_release=self.tabs.set_active_item)
|
||||||
|
new_tab._tabs = self.tabs
|
||||||
|
self.tabs.ids.container.add_widget(new_tab, index=index)
|
||||||
|
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
|
||||||
|
else:
|
||||||
|
self.tabs.add_widget(new_tab)
|
||||||
|
self.tabs.carousel.add_widget(new_tab.content)
|
||||||
return new_tab
|
return new_tab
|
||||||
|
|
||||||
def update_texts(self, dt):
|
def update_texts(self, dt):
|
||||||
if hasattr(self.tabs.content.children[0], "fix_heights"):
|
for slide in self.tabs.carousel.slides:
|
||||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
if hasattr(slide, "fix_heights"):
|
||||||
|
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||||
if self.ctx.server:
|
if self.ctx.server:
|
||||||
self.title = self.base_title + " " + Utils.__version__ + \
|
self.title = self.base_title + " " + Utils.__version__ + \
|
||||||
f" | Connected to: {self.ctx.server_address} " \
|
f" | Connected to: {self.ctx.server_address} " \
|
||||||
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
|
||||||
self.server_connect_button.text = "Disconnect"
|
self.server_connect_button._button_text.text = "Disconnect"
|
||||||
self.server_connect_bar.readonly = True
|
self.server_connect_bar.readonly = True
|
||||||
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
|
||||||
self.progressbar.value = len(self.ctx.checked_locations)
|
self.progressbar.value = len(self.ctx.checked_locations)
|
||||||
else:
|
else:
|
||||||
self.server_connect_button.text = "Connect"
|
self.server_connect_button._button_text.text = "Connect"
|
||||||
self.server_connect_bar.readonly = False
|
self.server_connect_bar.readonly = False
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.progressbar.value = 0
|
self.progressbar.value = 0
|
||||||
@@ -742,8 +1023,8 @@ class GameManager(App):
|
|||||||
|
|
||||||
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 = MDLabel(text="Energy Link: Standby",
|
||||||
size_hint_x=None, width=150)
|
size_hint_x=None, width=150, halign="center")
|
||||||
self.connect_layout.add_widget(self.energy_link_label)
|
self.connect_layout.add_widget(self.energy_link_label)
|
||||||
|
|
||||||
def set_new_energy_link_value(self):
|
def set_new_energy_link_value(self):
|
||||||
@@ -779,8 +1060,9 @@ class LogtoUI(logging.Handler):
|
|||||||
self.on_log(self.format(record))
|
self.on_log(self.format(record))
|
||||||
|
|
||||||
|
|
||||||
class UILog(RecycleView):
|
class UILog(MDRecycleView):
|
||||||
messages: typing.ClassVar[int] # comes from kv file
|
messages: typing.ClassVar[int] # comes from kv file
|
||||||
|
adaptive_height = True
|
||||||
|
|
||||||
def __init__(self, *loggers_to_handle, **kwargs):
|
def __init__(self, *loggers_to_handle, **kwargs):
|
||||||
super(UILog, self).__init__(**kwargs)
|
super(UILog, self).__init__(**kwargs)
|
||||||
@@ -807,17 +1089,24 @@ class UILog(RecycleView):
|
|||||||
element.height = element.texture_size[1]
|
element.height = element.texture_size[1]
|
||||||
|
|
||||||
|
|
||||||
class HintLayout(BoxLayout):
|
class HintLayout(MDBoxLayout):
|
||||||
orientation = "vertical"
|
orientation = "vertical"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
boxlayout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
|
boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40))
|
||||||
boxlayout.add_widget(Label(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(30)))
|
boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None,
|
||||||
|
height=dp(40), width=dp(75), halign="center", valign="center"))
|
||||||
boxlayout.add_widget(AutocompleteHintInput())
|
boxlayout.add_widget(AutocompleteHintInput())
|
||||||
self.add_widget(boxlayout)
|
self.add_widget(boxlayout)
|
||||||
|
|
||||||
|
def fix_heights(self):
|
||||||
|
for child in self.children:
|
||||||
|
fix_func = getattr(child, "fix_heights", None)
|
||||||
|
if fix_func:
|
||||||
|
fix_func()
|
||||||
|
|
||||||
|
|
||||||
status_names: typing.Dict[HintStatus, str] = {
|
status_names: typing.Dict[HintStatus, str] = {
|
||||||
HintStatus.HINT_FOUND: "Found",
|
HintStatus.HINT_FOUND: "Found",
|
||||||
HintStatus.HINT_UNSPECIFIED: "Unspecified",
|
HintStatus.HINT_UNSPECIFIED: "Unspecified",
|
||||||
@@ -840,8 +1129,7 @@ status_sort_weights: dict[HintStatus, int] = {
|
|||||||
HintStatus.HINT_PRIORITY: 4,
|
HintStatus.HINT_PRIORITY: 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HintLog(MDRecycleView):
|
||||||
class HintLog(RecycleView):
|
|
||||||
header = {
|
header = {
|
||||||
"receiving": {"text": "[u]Receiving Player[/u]"},
|
"receiving": {"text": "[u]Receiving Player[/u]"},
|
||||||
"item": {"text": "[u]Item[/u]"},
|
"item": {"text": "[u]Item[/u]"},
|
||||||
@@ -852,7 +1140,7 @@ class HintLog(RecycleView):
|
|||||||
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
|
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
|
||||||
"striped": True,
|
"striped": True,
|
||||||
}
|
}
|
||||||
|
data: list[typing.Any]
|
||||||
sort_key: str = ""
|
sort_key: str = ""
|
||||||
reversed: bool = True
|
reversed: bool = True
|
||||||
|
|
||||||
@@ -865,7 +1153,7 @@ class HintLog(RecycleView):
|
|||||||
if not hints: # Fix the scrolling looking visually wrong in some edge cases
|
if not hints: # Fix the scrolling looking visually wrong in some edge cases
|
||||||
self.scroll_y = 1.0
|
self.scroll_y = 1.0
|
||||||
data = []
|
data = []
|
||||||
ctx = App.get_running_app().ctx
|
ctx = MDApp.get_running_app().ctx
|
||||||
for hint in hints:
|
for hint in hints:
|
||||||
if not hint.get("status"): # Allows connecting to old servers
|
if not hint.get("status"): # Allows connecting to old servers
|
||||||
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
|
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
|
||||||
@@ -915,6 +1203,7 @@ class HintLog(RecycleView):
|
|||||||
|
|
||||||
|
|
||||||
class ApAsyncImage(AsyncImage):
|
class ApAsyncImage(AsyncImage):
|
||||||
|
|
||||||
def is_uri(self, filename: str) -> bool:
|
def is_uri(self, filename: str) -> bool:
|
||||||
if filename.startswith("ap:"):
|
if filename.startswith("ap:"):
|
||||||
return True
|
return True
|
||||||
@@ -929,7 +1218,8 @@ class ImageLoaderPkgutil(ImageLoaderBase):
|
|||||||
data = pkgutil.get_data(module, path)
|
data = pkgutil.get_data(module, path)
|
||||||
return self._bytes_to_data(data)
|
return self._bytes_to_data(data)
|
||||||
|
|
||||||
def _bytes_to_data(self, data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
|
@staticmethod
|
||||||
|
def _bytes_to_data(data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
|
||||||
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
|
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
|
||||||
return loader.load(loader, io.BytesIO(data))
|
return loader.load(loader, io.BytesIO(data))
|
||||||
|
|
||||||
@@ -959,7 +1249,23 @@ class E(ExceptionHandler):
|
|||||||
class KivyJSONtoTextParser(JSONtoTextParser):
|
class KivyJSONtoTextParser(JSONtoTextParser):
|
||||||
# dummy class to absorb kvlang definitions
|
# dummy class to absorb kvlang definitions
|
||||||
class TextColors(Widget):
|
class TextColors(Widget):
|
||||||
pass
|
white: str = StringProperty("FFFFFF")
|
||||||
|
black: str = StringProperty("000000")
|
||||||
|
red: str = StringProperty("EE0000")
|
||||||
|
green: str = StringProperty("00FF7F")
|
||||||
|
yellow: str = StringProperty("FAFAD2")
|
||||||
|
blue: str = StringProperty("6495ED")
|
||||||
|
magenta: str = StringProperty("EE00EE")
|
||||||
|
cyan: str = StringProperty("00EEEE")
|
||||||
|
slateblue: str = StringProperty("6D8BE8")
|
||||||
|
plum: str = StringProperty("AF99EF")
|
||||||
|
salmon: str = StringProperty("FA8072")
|
||||||
|
orange: str = StringProperty("FF7700")
|
||||||
|
# KivyMD parameters
|
||||||
|
theme_style: str = StringProperty("Dark")
|
||||||
|
primary_palette: str = StringProperty("Lightsteelblue")
|
||||||
|
dynamic_scheme_name: str = StringProperty("VIBRANT")
|
||||||
|
dynamic_scheme_contrast: int = NumericProperty(0)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries
|
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
colorama>=0.4.6
|
colorama>=0.4.6
|
||||||
websockets>=13.0.1,<14
|
websockets>=13.0.1,<14
|
||||||
PyYAML>=6.0.2
|
PyYAML>=6.0.2
|
||||||
jellyfish>=1.1.0
|
jellyfish>=1.1.3
|
||||||
jinja2>=3.1.4
|
jinja2>=3.1.6
|
||||||
schema>=0.7.7
|
schema>=0.7.7
|
||||||
kivy>=2.3.0
|
kivy>=2.3.1
|
||||||
bsdiff4>=1.2.4
|
bsdiff4>=1.2.6
|
||||||
platformdirs>=4.2.2
|
platformdirs>=4.3.6
|
||||||
certifi>=2024.12.14
|
certifi>=2025.4.26
|
||||||
cython>=3.0.11
|
cython>=3.0.12
|
||||||
cymem>=2.0.8
|
cymem>=2.0.11
|
||||||
orjson>=3.10.7
|
orjson>=3.10.15
|
||||||
typing_extensions>=4.12.2
|
typing_extensions>=4.12.2
|
||||||
|
pyshortcuts>=1.9.1
|
||||||
|
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
|
||||||
|
kivymd>=2.0.1.dev0
|
||||||
|
|||||||
12
setup.py
12
setup.py
@@ -19,7 +19,7 @@ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
|
|||||||
|
|
||||||
|
|
||||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
requirement = 'cx-Freeze==7.2.0'
|
requirement = 'cx-Freeze==8.0.0'
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
try:
|
try:
|
||||||
@@ -72,7 +72,6 @@ non_apworlds: Set[str] = {
|
|||||||
"Ocarina of Time",
|
"Ocarina of Time",
|
||||||
"Overcooked! 2",
|
"Overcooked! 2",
|
||||||
"Raft",
|
"Raft",
|
||||||
"Slay the Spire",
|
|
||||||
"Sudoku",
|
"Sudoku",
|
||||||
"Super Mario 64",
|
"Super Mario 64",
|
||||||
"VVVVVV",
|
"VVVVVV",
|
||||||
@@ -154,7 +153,7 @@ if os.path.exists("X:/pw.txt"):
|
|||||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||||
pw = f.read()
|
pw = f.read()
|
||||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
||||||
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
|
||||||
else:
|
else:
|
||||||
signtool = None
|
signtool = None
|
||||||
|
|
||||||
@@ -629,12 +628,13 @@ cx_Freeze.setup(
|
|||||||
ext_modules=cythonize("_speedups.pyx"),
|
ext_modules=cythonize("_speedups.pyx"),
|
||||||
options={
|
options={
|
||||||
"build_exe": {
|
"build_exe": {
|
||||||
"packages": ["worlds", "kivy", "cymem", "websockets"],
|
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
|
||||||
"includes": [],
|
"includes": [],
|
||||||
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
||||||
"pandas", "zstandard"],
|
"pandas"],
|
||||||
|
"zip_includes": [],
|
||||||
"zip_include_packages": ["*"],
|
"zip_include_packages": ["*"],
|
||||||
"zip_exclude_packages": ["worlds", "sc2"],
|
"zip_exclude_packages": ["worlds", "sc2", "kivymd"],
|
||||||
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
|
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
|
||||||
"include_msvcr": False,
|
"include_msvcr": False,
|
||||||
"replace_paths": ["*."],
|
"replace_paths": ["*."],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from typing import Callable
|
||||||
import unittest
|
import unittest
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ def generate_entrance_pair(region: Region, name_suffix: str, group: int):
|
|||||||
|
|
||||||
|
|
||||||
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
|
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
|
||||||
region_type: type[Region] = Region):
|
region_creator: Callable[[str, int, MultiWorld], Region] = Region):
|
||||||
"""
|
"""
|
||||||
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
|
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
|
||||||
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
|
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
|
||||||
@@ -44,7 +45,7 @@ def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length:
|
|||||||
for col in range(grid_side_length):
|
for col in range(grid_side_length):
|
||||||
index = row * grid_side_length + col
|
index = row * grid_side_length + col
|
||||||
name = f"region{index}"
|
name = f"region{index}"
|
||||||
region = region_type(name, 1, multiworld)
|
region = region_creator(name, 1, multiworld)
|
||||||
multiworld.regions.append(region)
|
multiworld.regions.append(region)
|
||||||
generate_locations(region_size, 1, region=region, tag=f"_{name}")
|
generate_locations(region_size, 1, region=region, tag=f"_{name}")
|
||||||
|
|
||||||
@@ -65,8 +66,10 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
"""tests that get_targets shuffles targets between groups when requested"""
|
"""tests that get_targets shuffles targets between groups when requested"""
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
generate_disconnected_region_grid(multiworld, 5)
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
||||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
for entrance in region.entrances if not entrance.parent_region]
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
for entrance in er_targets:
|
for entrance in er_targets:
|
||||||
@@ -86,8 +89,10 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
"""tests that get_targets does not shuffle targets between groups when requested"""
|
"""tests that get_targets does not shuffle targets between groups when requested"""
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
generate_disconnected_region_grid(multiworld, 5)
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region])
|
||||||
|
|
||||||
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
||||||
er_targets = [entrance for region in multiworld.get_regions(1)
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
for entrance in region.entrances if not entrance.parent_region]
|
for entrance in region.entrances if not entrance.parent_region]
|
||||||
for entrance in er_targets:
|
for entrance in er_targets:
|
||||||
@@ -99,6 +104,30 @@ class TestEntranceLookup(unittest.TestCase):
|
|||||||
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
|
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
|
||||||
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
|
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
|
||||||
|
|
||||||
|
def test_selective_dead_ends(self):
|
||||||
|
"""test that entrances that EntranceLookup has not been told to consider are ignored when finding dead-ends"""
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
|
exits_set = set([ex for region in multiworld.get_regions(1)
|
||||||
|
for ex in region.exits if not ex.connected_region
|
||||||
|
and ex.name != "region20_right" and ex.name != "region21_left"])
|
||||||
|
|
||||||
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
|
||||||
|
er_targets = [entrance for region in multiworld.get_regions(1)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region and
|
||||||
|
entrance.name != "region20_right" and entrance.name != "region21_left"]
|
||||||
|
for entrance in er_targets:
|
||||||
|
lookup.add(entrance)
|
||||||
|
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
|
||||||
|
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
|
||||||
|
# the top entrance from region 15 should be considered a dead-end
|
||||||
|
dead_end_region = multiworld.get_region("region20", 1)
|
||||||
|
for dead_end in dead_end_region.entrances:
|
||||||
|
if dead_end.name == "region20_top":
|
||||||
|
break
|
||||||
|
# there should be only this one dead-end
|
||||||
|
self.assertTrue(dead_end in lookup.dead_ends)
|
||||||
|
self.assertEqual(len(lookup.dead_ends), 1)
|
||||||
|
|
||||||
class TestBakeTargetGroupLookup(unittest.TestCase):
|
class TestBakeTargetGroupLookup(unittest.TestCase):
|
||||||
def test_lookup_generation(self):
|
def test_lookup_generation(self):
|
||||||
@@ -148,7 +177,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
|
|||||||
e.randomization_group = 1
|
e.randomization_group = 1
|
||||||
e.connect(r2)
|
e.connect(r2)
|
||||||
|
|
||||||
disconnect_entrance_for_randomization(e)
|
disconnect_entrance_for_randomization(e, one_way_target_name="foo")
|
||||||
|
|
||||||
self.assertIsNone(e.connected_region)
|
self.assertIsNone(e.connected_region)
|
||||||
self.assertEqual([], r1.entrances)
|
self.assertEqual([], r1.entrances)
|
||||||
@@ -158,10 +187,22 @@ class TestDisconnectForRandomization(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(1, len(r2.entrances))
|
self.assertEqual(1, len(r2.entrances))
|
||||||
self.assertIsNone(r2.entrances[0].parent_region)
|
self.assertIsNone(r2.entrances[0].parent_region)
|
||||||
self.assertEqual("r2", r2.entrances[0].name)
|
self.assertEqual("foo", r2.entrances[0].name)
|
||||||
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
||||||
self.assertEqual(1, r2.entrances[0].randomization_group)
|
self.assertEqual(1, r2.entrances[0].randomization_group)
|
||||||
|
|
||||||
|
def test_disconnect_default_1way_no_vanilla_target_raises(self):
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
r1 = Region("r1", 1, multiworld)
|
||||||
|
r2 = Region("r2", 1, multiworld)
|
||||||
|
e = r1.create_exit("e")
|
||||||
|
e.randomization_type = EntranceType.ONE_WAY
|
||||||
|
e.randomization_group = 1
|
||||||
|
e.connect(r2)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
disconnect_entrance_for_randomization(e)
|
||||||
|
|
||||||
def test_disconnect_uses_alternate_group(self):
|
def test_disconnect_uses_alternate_group(self):
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
r1 = Region("r1", 1, multiworld)
|
r1 = Region("r1", 1, multiworld)
|
||||||
@@ -171,7 +212,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
|
|||||||
e.randomization_group = 1
|
e.randomization_group = 1
|
||||||
e.connect(r2)
|
e.connect(r2)
|
||||||
|
|
||||||
disconnect_entrance_for_randomization(e, 2)
|
disconnect_entrance_for_randomization(e, 2, "foo")
|
||||||
|
|
||||||
self.assertIsNone(e.connected_region)
|
self.assertIsNone(e.connected_region)
|
||||||
self.assertEqual([], r1.entrances)
|
self.assertEqual([], r1.entrances)
|
||||||
@@ -181,7 +222,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(1, len(r2.entrances))
|
self.assertEqual(1, len(r2.entrances))
|
||||||
self.assertIsNone(r2.entrances[0].parent_region)
|
self.assertIsNone(r2.entrances[0].parent_region)
|
||||||
self.assertEqual("r2", r2.entrances[0].name)
|
self.assertEqual("foo", r2.entrances[0].name)
|
||||||
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
|
||||||
self.assertEqual(2, r2.entrances[0].randomization_group)
|
self.assertEqual(2, r2.entrances[0].randomization_group)
|
||||||
|
|
||||||
@@ -218,7 +259,7 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
self.assertEqual(80, len(result.pairings))
|
self.assertEqual(80, len(result.pairings))
|
||||||
self.assertEqual(80, len(result.placements))
|
self.assertEqual(80, len(result.placements))
|
||||||
|
|
||||||
def test_coupling(self):
|
def test_coupled(self):
|
||||||
"""tests that in coupled mode, all 2 way transitions have an inverse"""
|
"""tests that in coupled mode, all 2 way transitions have an inverse"""
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
generate_disconnected_region_grid(multiworld, 5)
|
generate_disconnected_region_grid(multiworld, 5)
|
||||||
@@ -236,6 +277,36 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
# if we didn't visit every placement the verification on_connect doesn't really mean much
|
# if we didn't visit every placement the verification on_connect doesn't really mean much
|
||||||
self.assertEqual(len(result.placements), seen_placement_count)
|
self.assertEqual(len(result.placements), seen_placement_count)
|
||||||
|
|
||||||
|
def test_uncoupled_succeeds_stage1_indirect_condition(self):
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
menu = multiworld.get_region("Menu", 1)
|
||||||
|
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
|
||||||
|
end = Region("End", 1, multiworld)
|
||||||
|
multiworld.regions.append(end)
|
||||||
|
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
|
||||||
|
multiworld.register_indirect_condition(end, None)
|
||||||
|
|
||||||
|
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||||
|
self.assertSetEqual({
|
||||||
|
("Menu_right", "End_left"),
|
||||||
|
("End_left", "Menu_right")
|
||||||
|
}, set(result.pairings))
|
||||||
|
|
||||||
|
def test_coupled_succeeds_stage1_indirect_condition(self):
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
menu = multiworld.get_region("Menu", 1)
|
||||||
|
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
|
||||||
|
end = Region("End", 1, multiworld)
|
||||||
|
multiworld.regions.append(end)
|
||||||
|
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
|
||||||
|
multiworld.register_indirect_condition(end, None)
|
||||||
|
|
||||||
|
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup)
|
||||||
|
self.assertSetEqual({
|
||||||
|
("Menu_right", "End_left"),
|
||||||
|
("End_left", "Menu_right")
|
||||||
|
}, set(result.pairings))
|
||||||
|
|
||||||
def test_uncoupled(self):
|
def test_uncoupled(self):
|
||||||
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
|
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
@@ -395,7 +466,7 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
entrance_type = CustomEntrance
|
entrance_type = CustomEntrance
|
||||||
|
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion)
|
generate_disconnected_region_grid(multiworld, 5, region_creator=CustomRegion)
|
||||||
|
|
||||||
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
|
||||||
directionally_matched_group_lookup)
|
directionally_matched_group_lookup)
|
||||||
|
|||||||
@@ -47,13 +47,39 @@ class TestIDs(unittest.TestCase):
|
|||||||
"""Test that a game doesn't have item id overlap within its own datapackage"""
|
"""Test that a game doesn't have item id overlap within its own datapackage"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
with self.subTest(game=gamename):
|
with self.subTest(game=gamename):
|
||||||
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
|
len_item_id_to_name = len(world_type.item_id_to_name)
|
||||||
|
len_item_name_to_id = len(world_type.item_name_to_id)
|
||||||
|
|
||||||
|
if len_item_id_to_name != len_item_name_to_id:
|
||||||
|
self.assertCountEqual(
|
||||||
|
world_type.item_id_to_name.values(),
|
||||||
|
world_type.item_name_to_id.keys(),
|
||||||
|
"\nThese items have overlapping ids with other items in its own world")
|
||||||
|
self.assertCountEqual(
|
||||||
|
world_type.item_id_to_name.keys(),
|
||||||
|
world_type.item_name_to_id.values(),
|
||||||
|
"\nThese items have overlapping names with other items in its own world")
|
||||||
|
|
||||||
|
self.assertEqual(len_item_id_to_name, len_item_name_to_id)
|
||||||
|
|
||||||
def test_duplicate_location_ids(self):
|
def test_duplicate_location_ids(self):
|
||||||
"""Test that a game doesn't have location id overlap within its own datapackage"""
|
"""Test that a game doesn't have location id overlap within its own datapackage"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
with self.subTest(game=gamename):
|
with self.subTest(game=gamename):
|
||||||
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
|
len_location_id_to_name = len(world_type.location_id_to_name)
|
||||||
|
len_location_name_to_id = len(world_type.location_name_to_id)
|
||||||
|
|
||||||
|
if len_location_id_to_name != len_location_name_to_id:
|
||||||
|
self.assertCountEqual(
|
||||||
|
world_type.location_id_to_name.values(),
|
||||||
|
world_type.location_name_to_id.keys(),
|
||||||
|
"\nThese locations have overlapping ids with other locations in its own world")
|
||||||
|
self.assertCountEqual(
|
||||||
|
world_type.location_id_to_name.keys(),
|
||||||
|
world_type.location_name_to_id.values(),
|
||||||
|
"\nThese locations have overlapping names with other locations in its own world")
|
||||||
|
|
||||||
|
self.assertEqual(len_location_id_to_name, len_location_name_to_id)
|
||||||
|
|
||||||
def test_postgen_datapackage(self):
|
def test_postgen_datapackage(self):
|
||||||
"""Generates a solo multiworld and checks that the datapackage is still valid"""
|
"""Generates a solo multiworld and checks that the datapackage is still valid"""
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from argparse import Namespace
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState, MultiWorld
|
||||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
from Fill import distribute_items_restrictive
|
||||||
|
from Options import ItemLinks
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister, World, call_all
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +87,47 @@ class TestBase(unittest.TestCase):
|
|||||||
multiworld = setup_solo_multiworld(world_type)
|
multiworld = setup_solo_multiworld(world_type)
|
||||||
for item in multiworld.itempool:
|
for item in multiworld.itempool:
|
||||||
self.assertIn(item.name, world_type.item_name_to_id)
|
self.assertIn(item.name, world_type.item_name_to_id)
|
||||||
|
|
||||||
|
def test_item_links(self) -> None:
|
||||||
|
"""
|
||||||
|
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
|
||||||
|
"""
|
||||||
|
def setup_link_multiworld(world: Type[World], link_replace: bool) -> None:
|
||||||
|
multiworld = MultiWorld(2)
|
||||||
|
multiworld.game = {1: world.game, 2: world.game}
|
||||||
|
multiworld.player_name = {1: "Linker 1", 2: "Linker 2"}
|
||||||
|
multiworld.set_seed()
|
||||||
|
item_link_group = [{
|
||||||
|
"name": "ItemLinkTest",
|
||||||
|
"item_pool": ["Everything"],
|
||||||
|
"link_replacement": link_replace,
|
||||||
|
"replacement_item": None,
|
||||||
|
}]
|
||||||
|
args = Namespace()
|
||||||
|
for name, option in world.options_dataclass.type_hints.items():
|
||||||
|
setattr(args, name, {1: option.from_any(option.default), 2: option.from_any(option.default)})
|
||||||
|
setattr(args, "item_links",
|
||||||
|
{1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)})
|
||||||
|
multiworld.set_options(args)
|
||||||
|
multiworld.set_item_links()
|
||||||
|
# groups get added to state during its constructor so this has to be after item links are set
|
||||||
|
multiworld.state = CollectionState(multiworld)
|
||||||
|
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic")
|
||||||
|
for step in gen_steps:
|
||||||
|
call_all(multiworld, step)
|
||||||
|
# link the items together and attempt to fill
|
||||||
|
multiworld.link_items()
|
||||||
|
multiworld._all_state = None
|
||||||
|
call_all(multiworld, "pre_fill")
|
||||||
|
distribute_items_restrictive(multiworld)
|
||||||
|
call_all(multiworld, "post_fill")
|
||||||
|
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
|
||||||
|
|
||||||
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest("Can generate with link replacement", game=game_name):
|
||||||
|
setup_link_multiworld(world_type, True)
|
||||||
|
with self.subTest("Can generate without link replacement", game=game_name):
|
||||||
|
setup_link_multiworld(world_type, False)
|
||||||
|
|
||||||
def test_itempool_not_modified(self):
|
def test_itempool_not_modified(self):
|
||||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||||
|
|||||||
14
test/general/test_packages.py
Normal file
14
test/general/test_packages.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackages(unittest.TestCase):
|
||||||
|
def test_packages_have_init(self):
|
||||||
|
"""Test that all world folders containing .py files also have a __init__.py file,
|
||||||
|
to indicate full package rather than namespace package."""
|
||||||
|
import Utils
|
||||||
|
|
||||||
|
worlds_path = Utils.local_path("worlds")
|
||||||
|
for dirpath, dirnames, filenames in os.walk(worlds_path):
|
||||||
|
with self.subTest(directory=dirpath):
|
||||||
|
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))
|
||||||
11
test/general/test_patches.py
Normal file
11
test/general/test_patches.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import unittest
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from worlds.Files import AutoPatchRegister
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatches(unittest.TestCase):
|
||||||
|
def test_patch_name_matches_game(self) -> None:
|
||||||
|
for game_name in AutoPatchRegister.patch_types:
|
||||||
|
with self.subTest(game=game_name):
|
||||||
|
self.assertIn(game_name, AutoWorldRegister.world_types.keys(),
|
||||||
|
f"Patch '{game_name}' does not match the name of any world.")
|
||||||
19
test/general/test_requirements.py
Normal file
19
test/general/test_requirements.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(unittest.TestCase):
|
||||||
|
def test_requirements_file_ends_on_newline(self):
|
||||||
|
"""Test that all requirements files end on a newline"""
|
||||||
|
import Utils
|
||||||
|
requirements_files = [Utils.local_path("requirements.txt"),
|
||||||
|
Utils.local_path("WebHostLib", "requirements.txt")]
|
||||||
|
worlds_path = Utils.local_path("worlds")
|
||||||
|
for entry in os.listdir(worlds_path):
|
||||||
|
requirements_path = os.path.join(worlds_path, entry, "requirements.txt")
|
||||||
|
if os.path.isfile(requirements_path):
|
||||||
|
requirements_files.append(requirements_path)
|
||||||
|
for requirements_file in requirements_files:
|
||||||
|
with self.subTest(path=requirements_file):
|
||||||
|
with open(requirements_file) as f:
|
||||||
|
self.assertEqual(f.read()[-1], "\n")
|
||||||
@@ -80,8 +80,8 @@ class Client:
|
|||||||
"version": {
|
"version": {
|
||||||
"class": "Version",
|
"class": "Version",
|
||||||
"major": 0,
|
"major": 0,
|
||||||
"minor": 4,
|
"minor": 6,
|
||||||
"build": 6,
|
"build": 0,
|
||||||
},
|
},
|
||||||
"items_handling": 0,
|
"items_handling": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from typing import List, Tuple
|
from typing import ClassVar, List, Tuple
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Location, MultiWorld
|
from BaseClasses import CollectionState, Location, MultiWorld
|
||||||
@@ -7,6 +7,7 @@ from Fill import distribute_items_restrictive
|
|||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
|
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
|
||||||
from ..general import gen_steps, setup_multiworld
|
from ..general import gen_steps, setup_multiworld
|
||||||
|
from ..param import classvar_matrix
|
||||||
|
|
||||||
|
|
||||||
class MultiworldTestBase(TestCase):
|
class MultiworldTestBase(TestCase):
|
||||||
@@ -63,15 +64,18 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
|||||||
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
|
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
|
||||||
|
|
||||||
|
|
||||||
|
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
|
||||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||||
|
game: ClassVar[str]
|
||||||
|
|
||||||
def test_two_player_single_game_fills(self) -> None:
|
def test_two_player_single_game_fills(self) -> None:
|
||||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||||
for world_type in AutoWorldRegister.world_types.values():
|
world_type = AutoWorldRegister.world_types[self.game]
|
||||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||||
for world in self.multiworld.worlds.values():
|
for world in self.multiworld.worlds.values():
|
||||||
world.options.accessibility.value = Accessibility.option_full
|
world.options.accessibility.value = Accessibility.option_full
|
||||||
self.assertSteps(gen_steps)
|
self.assertSteps(gen_steps)
|
||||||
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
|
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
|
||||||
distribute_items_restrictive(self.multiworld)
|
distribute_items_restrictive(self.multiworld)
|
||||||
call_all(self.multiworld, "post_fill")
|
call_all(self.multiworld, "post_fill")
|
||||||
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
|
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
|
||||||
|
|||||||
46
test/param.py
Normal file
46
test/param.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import itertools
|
||||||
|
import sys
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def classvar_matrix(**kwargs: Iterable[Any]) -> Callable[[type], None]:
|
||||||
|
"""
|
||||||
|
Create a new class for each variation of input, allowing to generate a TestCase matrix / parametrization that
|
||||||
|
supports multi-threading and has better reporting for ``unittest --durations=...`` and ``pytest --durations=...``
|
||||||
|
than subtests.
|
||||||
|
|
||||||
|
The kwargs will be set as ClassVars in the newly created classes. Use as ::
|
||||||
|
|
||||||
|
@classvar_matrix(var_name=[value1, value2])
|
||||||
|
class MyTestCase(unittest.TestCase):
|
||||||
|
var_name: typing.ClassVar[...]
|
||||||
|
|
||||||
|
:param kwargs: A dict of ClassVars to set, where key is the variable name and value is a list of all values.
|
||||||
|
:return: A decorator to be applied to a class.
|
||||||
|
"""
|
||||||
|
keys: tuple[str]
|
||||||
|
values: Iterable[Iterable[Any]]
|
||||||
|
keys, values = zip(*kwargs.items())
|
||||||
|
values = map(lambda v: sorted(v) if isinstance(v, (set, frozenset)) else v, values)
|
||||||
|
permutations_dicts = [dict(zip(keys, v)) for v in itertools.product(*values)]
|
||||||
|
|
||||||
|
def decorator(cls: type) -> None:
|
||||||
|
mod = sys.modules[cls.__module__]
|
||||||
|
|
||||||
|
for permutation in permutations_dicts:
|
||||||
|
|
||||||
|
class Unrolled(cls): # type: ignore
|
||||||
|
pass
|
||||||
|
|
||||||
|
for k, v in permutation.items():
|
||||||
|
setattr(Unrolled, k, v)
|
||||||
|
params = ", ".join([f"{k}={repr(v)}" for k, v in permutation.items()])
|
||||||
|
params = f"{{{params}}}"
|
||||||
|
|
||||||
|
Unrolled.__module__ = cls.__module__
|
||||||
|
Unrolled.__qualname__ = f"{cls.__qualname__}{params}"
|
||||||
|
setattr(mod, f"{cls.__name__}{params}", Unrolled)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -47,17 +47,6 @@ class TestCommonContext(unittest.IsolatedAsyncioTestCase):
|
|||||||
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
|
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
|
||||||
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
|
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
|
||||||
|
|
||||||
async def test_implicit_name_lookups(self):
|
|
||||||
# Items
|
|
||||||
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
|
|
||||||
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
|
|
||||||
assert self.ctx.item_names[-1] == "Nothing"
|
|
||||||
|
|
||||||
# Locations
|
|
||||||
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
|
|
||||||
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
|
|
||||||
assert self.ctx.location_names[-1] == "Cheat Console"
|
|
||||||
|
|
||||||
async def test_explicit_name_lookups(self):
|
async def test_explicit_name_lookups(self):
|
||||||
# Items
|
# Items
|
||||||
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"
|
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ class TestCacheSelf1(unittest.TestCase):
|
|||||||
self.assertFalse(o1.func(1) is o1.func(2))
|
self.assertFalse(o1.func(1) is o1.func(2))
|
||||||
self.assertFalse(o1.func(1) is o2.func(1))
|
self.assertFalse(o1.func(1) is o2.func(1))
|
||||||
|
|
||||||
|
def test_cache_default(self) -> None:
|
||||||
|
class Cls:
|
||||||
|
@cache_self1
|
||||||
|
def func(self, _: Any = 1) -> object:
|
||||||
|
return object()
|
||||||
|
|
||||||
|
o1 = Cls()
|
||||||
|
o2 = Cls()
|
||||||
|
self.assertIs(o1.func(), o1.func())
|
||||||
|
self.assertIs(o1.func(1), o1.func())
|
||||||
|
self.assertIsNot(o1.func(2), o1.func())
|
||||||
|
self.assertIsNot(o1.func(), o2.func())
|
||||||
|
|
||||||
def test_gc(self) -> None:
|
def test_gc(self) -> None:
|
||||||
# verify that we don't keep a global reference
|
# verify that we don't keep a global reference
|
||||||
import gc
|
import gc
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import unittest
|
|||||||
|
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
|
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet
|
||||||
|
|
||||||
|
|
||||||
class TestOptionPresets(unittest.TestCase):
|
class TestOptionPresets(unittest.TestCase):
|
||||||
@@ -19,7 +19,7 @@ class TestOptionPresets(unittest.TestCase):
|
|||||||
# pass in all plando options in case a preset wants to require certain plando options
|
# pass in all plando options in case a preset wants to require certain plando options
|
||||||
# for some reason
|
# for some reason
|
||||||
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
|
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
|
||||||
supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
|
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
|
||||||
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
if not any([issubclass(option.__class__, t) for t in supported_types]):
|
||||||
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
|
||||||
f"is not a supported type for webhost. "
|
f"is not a supported type for webhost. "
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def load_tests(loader, standard_tests, pattern):
|
|||||||
all_tests = [
|
all_tests = [
|
||||||
test_case for folder in folders if os.path.exists(folder)
|
test_case for folder in folders if os.path.exists(folder)
|
||||||
for test_collection in loader.discover(folder, top_level_dir=file_path)
|
for test_collection in loader.discover(folder, top_level_dir=file_path)
|
||||||
for test_suite in test_collection
|
for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite)
|
||||||
for test_case in test_suite
|
for test_case in test_suite
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from worlds.LauncherComponents import Component, SuffixIdentifier, Type, compone
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from SNIClient import SNIContext
|
from SNIClient import SNIContext
|
||||||
|
|
||||||
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"))
|
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
|
||||||
|
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
|
||||||
components.append(component)
|
components.append(component)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
|
|||||||
|
|
||||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState
|
||||||
|
from Utils import deprecate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||||
@@ -75,19 +76,20 @@ class AutoWorldRegister(type):
|
|||||||
# TODO - remove this once all worlds use options dataclasses
|
# TODO - remove this once all worlds use options dataclasses
|
||||||
if "options_dataclass" not in dct and "option_definitions" in dct:
|
if "options_dataclass" not in dct and "option_definitions" in dct:
|
||||||
# TODO - switch to deprecate after a version
|
# TODO - switch to deprecate after a version
|
||||||
if __debug__:
|
deprecate(f"{name} Assigned options through option_definitions which is now deprecated. "
|
||||||
logging.warning(f"{name} Assigned options through option_definitions which is now deprecated. "
|
"Please use options_dataclass instead.")
|
||||||
"Please use options_dataclass instead.")
|
|
||||||
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
|
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
|
||||||
bases=(PerGameCommonOptions,))
|
bases=(PerGameCommonOptions,))
|
||||||
|
|
||||||
# construct class
|
# construct class
|
||||||
new_class = super().__new__(mcs, name, bases, dct)
|
new_class = super().__new__(mcs, name, bases, dct)
|
||||||
|
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
||||||
if "game" in dct:
|
if "game" in dct:
|
||||||
if dct["game"] in AutoWorldRegister.world_types:
|
if dct["game"] in AutoWorldRegister.world_types:
|
||||||
raise RuntimeError(f"""Game {dct["game"]} already registered.""")
|
raise RuntimeError(f"""Game {dct["game"]} already registered in
|
||||||
|
{AutoWorldRegister.world_types[dct["game"]].__file__} when attempting to register from
|
||||||
|
{new_class.__file__}.""")
|
||||||
AutoWorldRegister.world_types[dct["game"]] = new_class
|
AutoWorldRegister.world_types[dct["game"]] = new_class
|
||||||
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
|
||||||
if ".apworld" in new_class.__file__:
|
if ".apworld" in new_class.__file__:
|
||||||
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
|
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
|
||||||
if "settings_key" not in dct:
|
if "settings_key" not in dct:
|
||||||
@@ -110,6 +112,16 @@ class AutoLogicRegister(type):
|
|||||||
elif not item_name.startswith("__"):
|
elif not item_name.startswith("__"):
|
||||||
if hasattr(CollectionState, item_name):
|
if hasattr(CollectionState, item_name):
|
||||||
raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}")
|
raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}")
|
||||||
|
|
||||||
|
assert callable(function) or "init_mixin" in dct, (
|
||||||
|
f"{name} defined class variable {item_name} without also having init_mixin.\n\n"
|
||||||
|
"Explanation:\n"
|
||||||
|
"Class variables that will be mutated need to be inintialized as instance variables in init_mixin.\n"
|
||||||
|
"If your LogicMixin variables aren't actually mutable / you don't intend to mutate them, "
|
||||||
|
"there is no point in using LogixMixin.\n"
|
||||||
|
"LogicMixin exists to track custom state variables that change when items are collected/removed."
|
||||||
|
)
|
||||||
|
|
||||||
setattr(CollectionState, item_name, function)
|
setattr(CollectionState, item_name, function)
|
||||||
return new_class
|
return new_class
|
||||||
|
|
||||||
@@ -473,7 +485,7 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
def get_filler_item_name(self) -> str:
|
def get_filler_item_name(self) -> str:
|
||||||
"""Called when the item pool needs to be filled with additional items to match location count."""
|
"""Called when the item pool needs to be filled with additional items to match location count."""
|
||||||
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
|
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
|
||||||
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys()))
|
return self.random.choice(tuple(self.item_name_to_id.keys()))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
|
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class Component:
|
|||||||
"""
|
"""
|
||||||
display_name: str
|
display_name: str
|
||||||
"""Used as the GUI button label and the component name in the CLI args"""
|
"""Used as the GUI button label and the component name in the CLI args"""
|
||||||
|
description: str
|
||||||
|
"""Optional description displayed on the GUI underneath the display name"""
|
||||||
type: Type
|
type: Type
|
||||||
"""
|
"""
|
||||||
Enum "Type" classification of component intent, for filtering in the Launcher GUI
|
Enum "Type" classification of component intent, for filtering in the Launcher GUI
|
||||||
@@ -58,8 +60,9 @@ class Component:
|
|||||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||||
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
|
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
|
||||||
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
|
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
|
||||||
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
|
game_name: Optional[str] = None, supports_uri: Optional[bool] = False, description: str = "") -> None:
|
||||||
self.display_name = display_name
|
self.display_name = display_name
|
||||||
|
self.description = description
|
||||||
self.script_name = script_name
|
self.script_name = script_name
|
||||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
@@ -88,7 +91,6 @@ processes = weakref.WeakSet()
|
|||||||
|
|
||||||
|
|
||||||
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||||
global processes
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||||
process.start()
|
process.start()
|
||||||
|
|||||||
@@ -276,6 +276,6 @@ def launch(*launch_args: str) -> None:
|
|||||||
|
|
||||||
Utils.init_logging("BizHawkClient", exception_logger="Client")
|
Utils.init_logging("BizHawkClient", exception_logger="Client")
|
||||||
import colorama
|
import colorama
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ mpyq>=0.2.5
|
|||||||
portpicker>=1.5.2
|
portpicker>=1.5.2
|
||||||
aiohttp>=3.8.4
|
aiohttp>=3.8.4
|
||||||
loguru>=0.7.0
|
loguru>=0.7.0
|
||||||
protobuf==3.20.3
|
protobuf==3.20.3
|
||||||
|
|||||||
@@ -238,14 +238,12 @@ class AdventureWorld(World):
|
|||||||
|
|
||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
|
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
|
||||||
|
|
||||||
set_rules = set_rules
|
|
||||||
|
|
||||||
def generate_basic(self) -> None:
|
|
||||||
self.multiworld.get_location("Chalice Home", self.player).place_locked_item(
|
self.multiworld.get_location("Chalice Home", self.player).place_locked_item(
|
||||||
self.create_event("Victory", ItemClassification.progression))
|
self.create_event("Victory", ItemClassification.progression))
|
||||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
|
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
|
||||||
|
|
||||||
|
set_rules = set_rules
|
||||||
|
|
||||||
def pre_fill(self):
|
def pre_fill(self):
|
||||||
# Place empty items in filler locations here, to limit
|
# Place empty items in filler locations here, to limit
|
||||||
# the number of exported empty items and the density of stuff in overworld.
|
# the number of exported empty items and the density of stuff in overworld.
|
||||||
|
|||||||
@@ -261,6 +261,6 @@ def launch():
|
|||||||
# options = Utils.get_options()
|
# options = Utils.get_options()
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ ahit_locations = {
|
|||||||
"Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"),
|
"Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"),
|
||||||
"Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"),
|
"Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"),
|
||||||
"Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"),
|
"Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"),
|
||||||
"Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Area", paintings=1),
|
"Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Behind Boss Firewall"),
|
||||||
"Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"),
|
"Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"),
|
||||||
"Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"),
|
"Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"),
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ ahit_locations = {
|
|||||||
"Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area",
|
"Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area",
|
||||||
required_hats=[HatType.DWELLER], paintings=2),
|
required_hats=[HatType.DWELLER], paintings=2),
|
||||||
|
|
||||||
"Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Area"),
|
"Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Boss Arena"),
|
||||||
|
|
||||||
"Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area",
|
"Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area",
|
||||||
hit_type=HitType.dweller_bell, paintings=1),
|
hit_type=HitType.dweller_bell, paintings=1),
|
||||||
@@ -411,7 +411,7 @@ act_completions = {
|
|||||||
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
||||||
required_hats=[HatType.SPRINT]),
|
required_hats=[HatType.SPRINT]),
|
||||||
|
|
||||||
"Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired",
|
"Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired - Post Fight",
|
||||||
hit_type=HitType.umbrella),
|
hit_type=HitType.umbrella),
|
||||||
|
|
||||||
"Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True),
|
"Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True),
|
||||||
@@ -976,7 +976,6 @@ event_locs = {
|
|||||||
**snatcher_coins,
|
**snatcher_coins,
|
||||||
"HUMT Access": LocData(0, "Heating Up Mafia Town"),
|
"HUMT Access": LocData(0, "Heating Up Mafia Town"),
|
||||||
"TOD Access": LocData(0, "Toilet of Doom"),
|
"TOD Access": LocData(0, "Toilet of Doom"),
|
||||||
"YCHE Access": LocData(0, "Your Contract has Expired"),
|
|
||||||
"AFR Access": LocData(0, "Alpine Free Roam"),
|
"AFR Access": LocData(0, "Alpine Free Roam"),
|
||||||
"TIHS Access": LocData(0, "The Illness has Spread"),
|
"TIHS Access": LocData(0, "The Illness has Spread"),
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING, Dict, Any
|
|||||||
from schema import Schema, Optional
|
from schema import Schema, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from worlds.AutoWorld import PerGameCommonOptions
|
from worlds.AutoWorld import PerGameCommonOptions
|
||||||
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup
|
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup, StartInventoryPool
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import HatInTimeWorld
|
from . import HatInTimeWorld
|
||||||
@@ -625,6 +625,8 @@ class ParadeTrapWeight(Range):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AHITOptions(PerGameCommonOptions):
|
class AHITOptions(PerGameCommonOptions):
|
||||||
|
start_inventory_from_pool: StartInventoryPool
|
||||||
|
|
||||||
EndGoal: EndGoal
|
EndGoal: EndGoal
|
||||||
ActRandomizer: ActRandomizer
|
ActRandomizer: ActRandomizer
|
||||||
ActPlando: ActPlando
|
ActPlando: ActPlando
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ def create_regions(world: "HatInTimeWorld"):
|
|||||||
sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest)
|
sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest)
|
||||||
sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest)
|
sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest)
|
||||||
sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest)
|
sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest)
|
||||||
create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest)
|
sf_finale = create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest)
|
||||||
|
|
||||||
# ------------------------------------------- ALPINE SKYLINE ------------------------------------------ #
|
# ------------------------------------------- ALPINE SKYLINE ------------------------------------------ #
|
||||||
alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship)
|
alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship)
|
||||||
@@ -386,11 +386,24 @@ def create_regions(world: "HatInTimeWorld"):
|
|||||||
create_rift_connections(world, create_region(world, "Time Rift - Bazaar"))
|
create_rift_connections(world, create_region(world, "Time Rift - Bazaar"))
|
||||||
|
|
||||||
sf_area: Region = create_region(world, "Subcon Forest Area")
|
sf_area: Region = create_region(world, "Subcon Forest Area")
|
||||||
|
sf_behind_boss_firewall: Region = create_region(world, "Subcon Forest Behind Boss Firewall")
|
||||||
|
sf_boss_arena: Region = create_region(world, "Subcon Forest Boss Arena")
|
||||||
|
sf_area.connect(sf_behind_boss_firewall, "SF Area -> SF Behind Boss Firewall")
|
||||||
|
sf_behind_boss_firewall.connect(sf_boss_arena, "SF Behind Boss Firewall -> SF Boss Arena")
|
||||||
sf_act1.connect(sf_area, "Subcon Forest Entrance CO")
|
sf_act1.connect(sf_area, "Subcon Forest Entrance CO")
|
||||||
sf_act2.connect(sf_area, "Subcon Forest Entrance SW")
|
sf_act2.connect(sf_area, "Subcon Forest Entrance SW")
|
||||||
sf_act3.connect(sf_area, "Subcon Forest Entrance TOD")
|
sf_act3.connect(sf_area, "Subcon Forest Entrance TOD")
|
||||||
sf_act4.connect(sf_area, "Subcon Forest Entrance QVM")
|
sf_act4.connect(sf_area, "Subcon Forest Entrance QVM")
|
||||||
sf_act5.connect(sf_area, "Subcon Forest Entrance MDS")
|
sf_act5.connect(sf_area, "Subcon Forest Entrance MDS")
|
||||||
|
# YCHE puts the player directly in the boss arena, with no access to the rest of Subcon Forest by default.
|
||||||
|
sf_finale.connect(sf_boss_arena, "Subcon Forest Entrance YCHE")
|
||||||
|
# To support the Snatcher Hover expert logic for Act Completion (Your Contract has Expired), the act completion has
|
||||||
|
# to go in a separate region because the Snatcher Hover gives direct access to the Act Completion, but does not
|
||||||
|
# give access to the act itself.
|
||||||
|
sf_finale_post_fight: Region = create_region(world, "Your Contract has Expired - Post Fight")
|
||||||
|
# This connection must never have any rules placed on it because they will not be inherited when setting up act
|
||||||
|
# connections, only the rules for the entrances to the act and the rules for the Act Completion are inherited.
|
||||||
|
sf_finale.connect(sf_finale_post_fight, "YCHE -> YCHE - Post Fight")
|
||||||
|
|
||||||
create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon"))
|
create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon"))
|
||||||
create_rift_connections(world, create_region(world, "Time Rift - Pipe"))
|
create_rift_connections(world, create_region(world, "Time Rift - Pipe"))
|
||||||
@@ -947,6 +960,16 @@ def get_shuffled_region(world: "HatInTimeWorld", region: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_region_shuffled_to(world: "HatInTimeWorld", region: str) -> str:
|
||||||
|
if world.options.ActRandomizer:
|
||||||
|
original_ci: str = chapter_act_info[region]
|
||||||
|
shuffled_ci = world.act_connections[original_ci]
|
||||||
|
return next(act_name for act_name, ci in chapter_act_info.items()
|
||||||
|
if ci == shuffled_ci)
|
||||||
|
else:
|
||||||
|
return region
|
||||||
|
|
||||||
|
|
||||||
def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int:
|
def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
region = world.multiworld.get_region(region_name, world.player)
|
region = world.multiworld.get_region(region_name, world.player)
|
||||||
|
|||||||
@@ -481,9 +481,8 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
|||||||
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player),
|
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player),
|
||||||
lambda state: has_paintings(state, world, 3))
|
lambda state: has_paintings(state, world, 3))
|
||||||
|
|
||||||
# Cherry bridge over boss arena gap (painting still expected)
|
# Cherry bridge over boss arena gap
|
||||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"), lambda state: True)
|
||||||
lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
|
||||||
|
|
||||||
set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player),
|
set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player),
|
||||||
lambda state: has_paintings(state, world, 2, True))
|
lambda state: has_paintings(state, world, 2, True))
|
||||||
@@ -566,27 +565,61 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
|||||||
lambda state: True)
|
lambda state: True)
|
||||||
|
|
||||||
# Expert: Cherry Hovering
|
# Expert: Cherry Hovering
|
||||||
subcon_area = world.multiworld.get_region("Subcon Forest Area", world.player)
|
# Skipping the boss firewall is possible with a Cherry Hover.
|
||||||
yche = world.multiworld.get_region("Your Contract has Expired", world.player)
|
set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
|
||||||
entrance = yche.connect(subcon_area, "Subcon Forest Entrance YCHE")
|
lambda state: has_paintings(state, world, 1, True))
|
||||||
|
# The boss arena gap can be crossed in reverse with a Cherry Hover.
|
||||||
|
subcon_boss_arena = world.get_region("Subcon Forest Boss Arena")
|
||||||
|
subcon_behind_boss_firewall = world.get_region("Subcon Forest Behind Boss Firewall")
|
||||||
|
subcon_boss_arena.connect(subcon_behind_boss_firewall, "SF Boss Arena -> SF Behind Boss Firewall")
|
||||||
|
|
||||||
if world.options.NoPaintingSkips:
|
subcon_area = world.get_region("Subcon Forest Area")
|
||||||
add_rule(entrance, lambda state: has_paintings(state, world, 1))
|
|
||||||
|
# The boss firewall can be skipped in reverse with a Cherry Hover, but it is not possible to remove the boss
|
||||||
|
# firewall from reverse because the paintings to burn to remove the firewall are on the other side of the firewall.
|
||||||
|
# Therefore, a painting skip is required. The paintings could be burned by already having access to
|
||||||
|
# "Subcon Forest Area" through another entrance, but making a new connection to "Subcon Forest Area" in that case
|
||||||
|
# would be pointless.
|
||||||
|
if not world.options.NoPaintingSkips:
|
||||||
|
# The import cannot be done at the module-level because it would cause a circular import.
|
||||||
|
from .Regions import get_region_shuffled_to
|
||||||
|
|
||||||
|
subcon_behind_boss_firewall.connect(subcon_area, "SF Behind Boss Firewall -> SF Area")
|
||||||
|
|
||||||
|
# Because the Your Contract has Expired entrance can now reach "Subcon Forest Area", it needs to be connected to
|
||||||
|
# each of the Subcon Forest Time Rift entrances, like the other Subcon Forest Acts.
|
||||||
|
yche = world.get_region("Your Contract has Expired")
|
||||||
|
|
||||||
|
def connect_to_shuffled_act_at(original_act_name):
|
||||||
|
region_name = get_region_shuffled_to(world, original_act_name)
|
||||||
|
return yche.connect(world.get_region(region_name), f"{original_act_name} Portal - Entrance YCHE")
|
||||||
|
|
||||||
|
# Rules copied from `Rules.set_rift_rules()` with painting logic removed because painting skips must be
|
||||||
|
# available.
|
||||||
|
entrance = connect_to_shuffled_act_at("Time Rift - Pipe")
|
||||||
|
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2"))
|
||||||
|
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 2").connected_region, entrance)
|
||||||
|
|
||||||
|
entrance = connect_to_shuffled_act_at("Time Rift - Village")
|
||||||
|
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4"))
|
||||||
|
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 4").connected_region, entrance)
|
||||||
|
|
||||||
|
entrance = connect_to_shuffled_act_at("Time Rift - Sleepy Subcon")
|
||||||
|
add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO"))
|
||||||
|
|
||||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||||
and has_paintings(state, world, 1, True))
|
and has_paintings(state, world, 1, True))
|
||||||
|
|
||||||
# Set painting rules only. Skipping paintings is determined in has_paintings
|
# Set painting rules only. Skipping paintings is determined in has_paintings
|
||||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
|
||||||
lambda state: has_paintings(state, world, 1, True))
|
|
||||||
set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player),
|
set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player),
|
||||||
lambda state: has_paintings(state, world, 3, True))
|
lambda state: has_paintings(state, world, 3, True))
|
||||||
|
|
||||||
# You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him
|
# You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him
|
||||||
subcon_area.connect(yche, "Snatcher Hover")
|
yche_post_fight = world.get_region("Your Contract has Expired - Post Fight")
|
||||||
set_rule(world.multiworld.get_location("Act Completion (Your Contract has Expired)", world.player),
|
subcon_area.connect(yche_post_fight, "Snatcher Hover")
|
||||||
lambda state: True)
|
# Cherry Hover from YCHE also works, so there are no requirements for the Act Completion.
|
||||||
|
set_rule(world.get_location("Act Completion (Your Contract has Expired)"), lambda state: True)
|
||||||
|
|
||||||
if world.is_dlc2():
|
if world.is_dlc2():
|
||||||
# Expert: clear Rush Hour with nothing
|
# Expert: clear Rush Hour with nothing
|
||||||
@@ -681,12 +714,18 @@ def set_subcon_rules(world: "HatInTimeWorld"):
|
|||||||
lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player)
|
lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player)
|
||||||
or can_use_hat(state, world, HatType.DWELLER))
|
or can_use_hat(state, world, HatType.DWELLER))
|
||||||
|
|
||||||
# You can't skip over the boss arena wall without cherry hover, so these two need to be set this way
|
# You can't skip over the boss arena wall without cherry hover.
|
||||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
|
||||||
lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world)
|
lambda state: has_paintings(state, world, 1, False))
|
||||||
and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
|
||||||
|
|
||||||
# The painting wall can't be skipped without cherry hover, which is Expert
|
# The hookpoints to cross the boss arena gap are only present in Toilet of Doom.
|
||||||
|
set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"),
|
||||||
|
lambda state: state.has("TOD Access", world.player)
|
||||||
|
and can_use_hookshot(state, world))
|
||||||
|
|
||||||
|
# The Act Completion is in the Toilet of Doom region, so the same rules as passing the boss firewall and crossing
|
||||||
|
# the boss arena gap are required. "TOD Access" is implied from the region so does not need to be included in the
|
||||||
|
# rule.
|
||||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||||
and has_paintings(state, world, 1, False))
|
and has_paintings(state, world, 1, False))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
|
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
|
||||||
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
|
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
|
||||||
calculate_yarn_costs, alps_hooks
|
calculate_yarn_costs, alps_hooks, junk_weights
|
||||||
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
|
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
|
||||||
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
|
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
|
||||||
get_total_locations
|
get_total_locations
|
||||||
@@ -78,6 +78,9 @@ class HatInTimeWorld(World):
|
|||||||
self.nyakuza_thug_items: Dict[str, int] = {}
|
self.nyakuza_thug_items: Dict[str, int] = {}
|
||||||
self.badge_seller_count: int = 0
|
self.badge_seller_count: int = 0
|
||||||
|
|
||||||
|
def get_filler_item_name(self) -> str:
|
||||||
|
return self.random.choices(list(junk_weights.keys()), weights=junk_weights.values(), k=1)[0]
|
||||||
|
|
||||||
def generate_early(self):
|
def generate_early(self):
|
||||||
adjust_options(self)
|
adjust_options(self)
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def KholdstareDefeatRule(state, player: int) -> bool:
|
|||||||
state.has('Fire Rod', player) or
|
state.has('Fire Rod', player) or
|
||||||
(
|
(
|
||||||
state.has('Bombos', player) and
|
state.has('Bombos', player) and
|
||||||
(has_sword(state, player) or state.multiworld.swordless[player])
|
(has_sword(state, player) or state.multiworld.worlds[player].options.swordless)
|
||||||
)
|
)
|
||||||
) and
|
) and
|
||||||
(
|
(
|
||||||
@@ -111,7 +111,7 @@ def KholdstareDefeatRule(state, player: int) -> bool:
|
|||||||
(
|
(
|
||||||
state.has('Fire Rod', player) and
|
state.has('Fire Rod', player) and
|
||||||
state.has('Bombos', player) and
|
state.has('Bombos', player) and
|
||||||
state.multiworld.swordless[player] and
|
state.multiworld.worlds[player].options.swordless and
|
||||||
can_extend_magic(state, player, 16)
|
can_extend_magic(state, player, 16)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -137,7 +137,7 @@ def AgahnimDefeatRule(state, player: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def GanonDefeatRule(state, player: int) -> bool:
|
def GanonDefeatRule(state, player: int) -> bool:
|
||||||
if state.multiworld.swordless[player]:
|
if state.multiworld.worlds[player].options.swordless:
|
||||||
return state.has('Hammer', player) and \
|
return state.has('Hammer', player) and \
|
||||||
has_fire_source(state, player) and \
|
has_fire_source(state, player) and \
|
||||||
state.has('Silver Bow', player) and \
|
state.has('Silver Bow', player) and \
|
||||||
@@ -146,7 +146,7 @@ def GanonDefeatRule(state, player: int) -> bool:
|
|||||||
can_hurt = has_beam_sword(state, player)
|
can_hurt = has_beam_sword(state, player)
|
||||||
common = can_hurt and has_fire_source(state, player)
|
common = can_hurt and has_fire_source(state, player)
|
||||||
# silverless ganon may be needed in anything higher than no glitches
|
# silverless ganon may be needed in anything higher than no glitches
|
||||||
if state.multiworld.glitches_required[player] != 'no_glitches':
|
if state.multiworld.worlds[player].options.glitches_required != 'no_glitches':
|
||||||
# need to light torch a sufficient amount of times
|
# need to light torch a sufficient amount of times
|
||||||
return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (
|
return common and (state.has('Tempered Sword', player) or state.has('Golden Sword', player) or (
|
||||||
state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or
|
state.has('Silver Bow', player) and can_shoot_arrows(state, player)) or
|
||||||
@@ -248,7 +248,7 @@ for location in boss_location_table:
|
|||||||
|
|
||||||
def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None:
|
def place_boss(world: "ALTTPWorld", boss: str, location: str, level: Optional[str]) -> None:
|
||||||
player = world.player
|
player = world.player
|
||||||
if location == 'Ganons Tower' and world.multiworld.mode[player] == 'inverted':
|
if location == 'Ganons Tower' and world.options.mode == 'inverted':
|
||||||
location = 'Inverted Ganons Tower'
|
location = 'Inverted Ganons Tower'
|
||||||
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
|
logging.debug('Placing boss %s at %s', boss, location + (' (' + level + ')' if level else ''))
|
||||||
world.dungeons[location].bosses[level] = BossFactory(boss, player)
|
world.dungeons[location].bosses[level] = BossFactory(boss, player)
|
||||||
@@ -260,9 +260,8 @@ def format_boss_location(location_name: str, level: str) -> str:
|
|||||||
|
|
||||||
def place_bosses(world: "ALTTPWorld") -> None:
|
def place_bosses(world: "ALTTPWorld") -> None:
|
||||||
multiworld = world.multiworld
|
multiworld = world.multiworld
|
||||||
player = world.player
|
|
||||||
# will either be an int or a lower case string with ';' between options
|
# will either be an int or a lower case string with ';' between options
|
||||||
boss_shuffle: Union[str, int] = multiworld.boss_shuffle[player].value
|
boss_shuffle: Union[str, int] = world.options.boss_shuffle.value
|
||||||
already_placed_bosses: List[str] = []
|
already_placed_bosses: List[str] = []
|
||||||
remaining_locations: List[Tuple[str, str]] = []
|
remaining_locations: List[Tuple[str, str]] = []
|
||||||
# handle plando
|
# handle plando
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def create_dungeons(world: "ALTTPWorld"):
|
|||||||
|
|
||||||
def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dungeon_items):
|
def make_dungeon(name, default_boss, dungeon_regions, big_key, small_keys, dungeon_items):
|
||||||
dungeon = Dungeon(name, dungeon_regions, big_key,
|
dungeon = Dungeon(name, dungeon_regions, big_key,
|
||||||
[] if multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal else small_keys,
|
[] if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal else small_keys,
|
||||||
dungeon_items, player)
|
dungeon_items, player)
|
||||||
for item in dungeon.all_items:
|
for item in dungeon.all_items:
|
||||||
item.dungeon = dungeon
|
item.dungeon = dungeon
|
||||||
@@ -143,7 +143,7 @@ def create_dungeons(world: "ALTTPWorld"):
|
|||||||
item_factory(['Small Key (Turtle Rock)'] * 6, world),
|
item_factory(['Small Key (Turtle Rock)'] * 6, world),
|
||||||
item_factory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], world))
|
item_factory(['Map (Turtle Rock)', 'Compass (Turtle Rock)'], world))
|
||||||
|
|
||||||
if multiworld.mode[player] != 'inverted':
|
if multiworld.worlds[player].options.mode != 'inverted':
|
||||||
AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None,
|
AT = make_dungeon('Agahnims Tower', 'Agahnim', ['Agahnims Tower', 'Agahnim 1'], None,
|
||||||
item_factory(['Small Key (Agahnims Tower)'] * 4, world), [])
|
item_factory(['Small Key (Agahnims Tower)'] * 4, world), [])
|
||||||
GT = make_dungeon('Ganons Tower', 'Agahnim2',
|
GT = make_dungeon('Ganons Tower', 'Agahnim2',
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ def link_entrances(world, player):
|
|||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
# if we do not shuffle, set default connections
|
# if we do not shuffle, set default connections
|
||||||
if world.entrance_shuffle[player] == 'vanilla':
|
if world.worlds[player].options.entrance_shuffle == 'vanilla':
|
||||||
for exitname, regionname in default_connections:
|
for exitname, regionname in default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
for exitname, regionname in default_dungeon_connections:
|
for exitname, regionname in default_dungeon_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_simple':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_simple':
|
||||||
for exitname, regionname in default_connections:
|
for exitname, regionname in default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_full':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_full':
|
||||||
for exitname, regionname in default_connections:
|
for exitname, regionname in default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ def link_entrances(world, player):
|
|||||||
lw_entrances = list(LW_Dungeon_Entrances)
|
lw_entrances = list(LW_Dungeon_Entrances)
|
||||||
dw_entrances = list(DW_Dungeon_Entrances)
|
dw_entrances = list(DW_Dungeon_Entrances)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# must connect front of hyrule castle to do escape
|
# must connect front of hyrule castle to do escape
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
else:
|
else:
|
||||||
@@ -56,7 +56,7 @@ def link_entrances(world, player):
|
|||||||
dw_entrances.append('Ganons Tower')
|
dw_entrances.append('Ganons Tower')
|
||||||
dungeon_exits.append('Ganons Tower Exit')
|
dungeon_exits.append('Ganons Tower Exit')
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# rest of hyrule castle must be in light world, so it has to be the one connected to east exit of desert
|
# rest of hyrule castle must be in light world, so it has to be the one connected to east exit of desert
|
||||||
hyrule_castle_exits = [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')]
|
hyrule_castle_exits = [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')]
|
||||||
connect_mandatory_exits(world, lw_entrances, hyrule_castle_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
|
connect_mandatory_exits(world, lw_entrances, hyrule_castle_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
|
||||||
@@ -65,9 +65,9 @@ def link_entrances(world, player):
|
|||||||
connect_mandatory_exits(world, lw_entrances, dungeon_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
|
connect_mandatory_exits(world, lw_entrances, dungeon_exits, list(LW_Dungeon_Entrances_Must_Exit), player)
|
||||||
connect_mandatory_exits(world, dw_entrances, dungeon_exits, list(DW_Dungeon_Entrances_Must_Exit), player)
|
connect_mandatory_exits(world, dw_entrances, dungeon_exits, list(DW_Dungeon_Entrances_Must_Exit), player)
|
||||||
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
|
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_crossed':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_crossed':
|
||||||
crossed_shuffle_dungeons(world, player)
|
crossed_shuffle_dungeons(world, player)
|
||||||
elif world.entrance_shuffle[player] == 'simple':
|
elif world.worlds[player].options.entrance_shuffle == 'simple':
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
|
|
||||||
old_man_entrances = list(Old_Man_Entrances)
|
old_man_entrances = list(Old_Man_Entrances)
|
||||||
@@ -138,7 +138,7 @@ def link_entrances(world, player):
|
|||||||
|
|
||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, single_doors, door_targets, player)
|
connect_doors(world, single_doors, door_targets, player)
|
||||||
elif world.entrance_shuffle[player] == 'restricted':
|
elif world.worlds[player].options.entrance_shuffle == 'restricted':
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
|
|
||||||
lw_entrances = list(LW_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
|
lw_entrances = list(LW_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
|
||||||
@@ -210,7 +210,7 @@ def link_entrances(world, player):
|
|||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, doors, door_targets, player)
|
connect_doors(world, doors, door_targets, player)
|
||||||
|
|
||||||
elif world.entrance_shuffle[player] == 'full':
|
elif world.worlds[player].options.entrance_shuffle == 'full':
|
||||||
skull_woods_shuffle(world, player)
|
skull_woods_shuffle(world, player)
|
||||||
|
|
||||||
lw_entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
|
lw_entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances)
|
||||||
@@ -227,7 +227,7 @@ def link_entrances(world, player):
|
|||||||
# tavern back door cannot be shuffled yet
|
# tavern back door cannot be shuffled yet
|
||||||
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# must connect front of hyrule castle to do escape
|
# must connect front of hyrule castle to do escape
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
else:
|
else:
|
||||||
@@ -264,7 +264,7 @@ def link_entrances(world, player):
|
|||||||
pass
|
pass
|
||||||
else: #if the cave wasn't placed we get here
|
else: #if the cave wasn't placed we get here
|
||||||
connect_caves(world, lw_entrances, [], old_man_house, player)
|
connect_caves(world, lw_entrances, [], old_man_house, player)
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# rest of hyrule castle must be in light world
|
# rest of hyrule castle must be in light world
|
||||||
connect_caves(world, lw_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
connect_caves(world, lw_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ def link_entrances(world, player):
|
|||||||
|
|
||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, doors, door_targets, player)
|
connect_doors(world, doors, door_targets, player)
|
||||||
elif world.entrance_shuffle[player] == 'crossed':
|
elif world.worlds[player].options.entrance_shuffle == 'crossed':
|
||||||
skull_woods_shuffle(world, player)
|
skull_woods_shuffle(world, player)
|
||||||
|
|
||||||
entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances + DW_Entrances + DW_Dungeon_Entrances + DW_Single_Cave_Doors)
|
entrances = list(LW_Entrances + LW_Dungeon_Entrances + LW_Single_Cave_Doors + Old_Man_Entrances + DW_Entrances + DW_Dungeon_Entrances + DW_Single_Cave_Doors)
|
||||||
@@ -331,7 +331,7 @@ def link_entrances(world, player):
|
|||||||
# tavern back door cannot be shuffled yet
|
# tavern back door cannot be shuffled yet
|
||||||
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# must connect front of hyrule castle to do escape
|
# must connect front of hyrule castle to do escape
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
else:
|
else:
|
||||||
@@ -348,7 +348,7 @@ def link_entrances(world, player):
|
|||||||
#place must-exit caves
|
#place must-exit caves
|
||||||
connect_mandatory_exits(world, entrances, caves, must_exits, player)
|
connect_mandatory_exits(world, entrances, caves, must_exits, player)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# rest of hyrule castle must be dealt with
|
# rest of hyrule castle must be dealt with
|
||||||
connect_caves(world, entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
connect_caves(world, entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ def link_entrances(world, player):
|
|||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, entrances, door_targets, player)
|
connect_doors(world, entrances, door_targets, player)
|
||||||
|
|
||||||
elif world.entrance_shuffle[player] == 'insanity':
|
elif world.worlds[player].options.entrance_shuffle == 'insanity':
|
||||||
# beware ye who enter here
|
# beware ye who enter here
|
||||||
|
|
||||||
entrances = LW_Entrances + LW_Dungeon_Entrances + DW_Entrances + DW_Dungeon_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave']
|
entrances = LW_Entrances + LW_Dungeon_Entrances + DW_Entrances + DW_Dungeon_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave']
|
||||||
@@ -431,7 +431,7 @@ def link_entrances(world, player):
|
|||||||
# tavern back door cannot be shuffled yet
|
# tavern back door cannot be shuffled yet
|
||||||
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
connect_doors(world, ['Tavern North'], ['Tavern'], player)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# cannot move uncle cave
|
# cannot move uncle cave
|
||||||
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
|
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
|
||||||
connect_exit(world, 'Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance Stairs', player)
|
connect_exit(world, 'Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance Stairs', player)
|
||||||
@@ -464,7 +464,7 @@ def link_entrances(world, player):
|
|||||||
connect_entrance(world, hole, hole_targets.pop(), player)
|
connect_entrance(world, hole, hole_targets.pop(), player)
|
||||||
|
|
||||||
# hyrule castle handling
|
# hyrule castle handling
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# must connect front of hyrule castle to do escape
|
# must connect front of hyrule castle to do escape
|
||||||
connect_entrance(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_entrance(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
connect_exit(world, 'Hyrule Castle Exit (South)', 'Hyrule Castle Entrance (South)', player)
|
connect_exit(world, 'Hyrule Castle Exit (South)', 'Hyrule Castle Entrance (South)', player)
|
||||||
@@ -544,12 +544,12 @@ def link_entrances(world, player):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f'{world.entrance_shuffle[player]} Shuffling not supported yet. Player {world.get_player_name(player)}')
|
f'{world.worlds[player].options.entrance_shuffle} Shuffling not supported yet. Player {world.get_player_name(player)}')
|
||||||
|
|
||||||
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
||||||
overworld_glitch_connections(world, player)
|
overworld_glitch_connections(world, player)
|
||||||
# mandatory hybrid major glitches connections
|
# mandatory hybrid major glitches connections
|
||||||
if world.glitches_required[player] in ['hybrid_major_glitches', 'no_logic']:
|
if world.worlds[player].options.glitches_required in ['hybrid_major_glitches', 'no_logic']:
|
||||||
underworld_glitch_connections(world, player)
|
underworld_glitch_connections(world, player)
|
||||||
|
|
||||||
# check for swamp palace fix
|
# check for swamp palace fix
|
||||||
@@ -584,17 +584,17 @@ def link_inverted_entrances(world, player):
|
|||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
# if we do not shuffle, set default connections
|
# if we do not shuffle, set default connections
|
||||||
if world.entrance_shuffle[player] == 'vanilla':
|
if world.worlds[player].options.entrance_shuffle == 'vanilla':
|
||||||
for exitname, regionname in inverted_default_connections:
|
for exitname, regionname in inverted_default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
for exitname, regionname in inverted_default_dungeon_connections:
|
for exitname, regionname in inverted_default_dungeon_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_simple':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_simple':
|
||||||
for exitname, regionname in inverted_default_connections:
|
for exitname, regionname in inverted_default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_full':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_full':
|
||||||
for exitname, regionname in inverted_default_connections:
|
for exitname, regionname in inverted_default_connections:
|
||||||
connect_simple(world, exitname, regionname, player)
|
connect_simple(world, exitname, regionname, player)
|
||||||
|
|
||||||
@@ -649,9 +649,9 @@ def link_inverted_entrances(world, player):
|
|||||||
connect_mandatory_exits(world, lw_entrances, dungeon_exits, lw_dungeon_entrances_must_exit, player)
|
connect_mandatory_exits(world, lw_entrances, dungeon_exits, lw_dungeon_entrances_must_exit, player)
|
||||||
|
|
||||||
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
|
connect_caves(world, lw_entrances, dw_entrances, dungeon_exits, player)
|
||||||
elif world.entrance_shuffle[player] == 'dungeons_crossed':
|
elif world.worlds[player].options.entrance_shuffle == 'dungeons_crossed':
|
||||||
inverted_crossed_shuffle_dungeons(world, player)
|
inverted_crossed_shuffle_dungeons(world, player)
|
||||||
elif world.entrance_shuffle[player] == 'simple':
|
elif world.worlds[player].options.entrance_shuffle == 'simple':
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
|
|
||||||
old_man_entrances = list(Inverted_Old_Man_Entrances)
|
old_man_entrances = list(Inverted_Old_Man_Entrances)
|
||||||
@@ -748,7 +748,7 @@ def link_inverted_entrances(world, player):
|
|||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, single_doors, door_targets, player)
|
connect_doors(world, single_doors, door_targets, player)
|
||||||
|
|
||||||
elif world.entrance_shuffle[player] == 'restricted':
|
elif world.worlds[player].options.entrance_shuffle == 'restricted':
|
||||||
simple_shuffle_dungeons(world, player)
|
simple_shuffle_dungeons(world, player)
|
||||||
|
|
||||||
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Single_Cave_Doors)
|
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Single_Cave_Doors)
|
||||||
@@ -833,7 +833,7 @@ def link_inverted_entrances(world, player):
|
|||||||
doors = lw_entrances + dw_entrances
|
doors = lw_entrances + dw_entrances
|
||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, doors, door_targets, player)
|
connect_doors(world, doors, door_targets, player)
|
||||||
elif world.entrance_shuffle[player] == 'full':
|
elif world.worlds[player].options.entrance_shuffle == 'full':
|
||||||
skull_woods_shuffle(world, player)
|
skull_woods_shuffle(world, player)
|
||||||
|
|
||||||
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors)
|
lw_entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors)
|
||||||
@@ -984,7 +984,7 @@ def link_inverted_entrances(world, player):
|
|||||||
|
|
||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, doors, door_targets, player)
|
connect_doors(world, doors, door_targets, player)
|
||||||
elif world.entrance_shuffle[player] == 'crossed':
|
elif world.worlds[player].options.entrance_shuffle == 'crossed':
|
||||||
skull_woods_shuffle(world, player)
|
skull_woods_shuffle(world, player)
|
||||||
|
|
||||||
entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors + Inverted_Old_Man_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_DW_Single_Cave_Doors)
|
entrances = list(Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_LW_Single_Cave_Doors + Inverted_Old_Man_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_DW_Single_Cave_Doors)
|
||||||
@@ -1095,7 +1095,7 @@ def link_inverted_entrances(world, player):
|
|||||||
|
|
||||||
# place remaining doors
|
# place remaining doors
|
||||||
connect_doors(world, entrances, door_targets, player)
|
connect_doors(world, entrances, door_targets, player)
|
||||||
elif world.entrance_shuffle[player] == 'insanity':
|
elif world.worlds[player].options.entrance_shuffle == 'insanity':
|
||||||
# beware ye who enter here
|
# beware ye who enter here
|
||||||
|
|
||||||
entrances = Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_Old_Man_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods Second Section Door (West)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave', 'Hyrule Castle Entrance (South)']
|
entrances = Inverted_LW_Entrances + Inverted_LW_Dungeon_Entrances + Inverted_DW_Entrances + Inverted_DW_Dungeon_Entrances + Inverted_Old_Man_Entrances + Old_Man_Entrances + ['Skull Woods Second Section Door (East)', 'Skull Woods Second Section Door (West)', 'Skull Woods First Section Door', 'Kakariko Well Cave', 'Bat Cave Cave', 'North Fairy Cave', 'Sanctuary', 'Lost Woods Hideout Stump', 'Lumberjack Tree Cave', 'Hyrule Castle Entrance (South)']
|
||||||
@@ -1254,10 +1254,10 @@ def link_inverted_entrances(world, player):
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError('Shuffling not supported yet')
|
raise NotImplementedError('Shuffling not supported yet')
|
||||||
|
|
||||||
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
||||||
overworld_glitch_connections(world, player)
|
overworld_glitch_connections(world, player)
|
||||||
# mandatory hybrid major glitches connections
|
# mandatory hybrid major glitches connections
|
||||||
if world.glitches_required[player] in ['hybrid_major_glitches', 'no_logic']:
|
if world.worlds[player].options.glitches_required in ['hybrid_major_glitches', 'no_logic']:
|
||||||
underworld_glitch_connections(world, player)
|
underworld_glitch_connections(world, player)
|
||||||
|
|
||||||
# patch swamp drain
|
# patch swamp drain
|
||||||
@@ -1349,7 +1349,7 @@ def scramble_holes(world, player):
|
|||||||
else:
|
else:
|
||||||
hole_targets.append(('Pyramid Exit', 'Pyramid'))
|
hole_targets.append(('Pyramid Exit', 'Pyramid'))
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# cannot move uncle cave
|
# cannot move uncle cave
|
||||||
connect_two_way(world, 'Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Secret Entrance Exit', player)
|
connect_two_way(world, 'Hyrule Castle Secret Entrance Stairs', 'Hyrule Castle Secret Entrance Exit', player)
|
||||||
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
|
connect_entrance(world, 'Hyrule Castle Secret Entrance Drop', 'Hyrule Castle Secret Entrance', player)
|
||||||
@@ -1358,14 +1358,14 @@ def scramble_holes(world, player):
|
|||||||
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
|
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
|
||||||
|
|
||||||
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed
|
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed
|
||||||
if world.entrance_shuffle[player] == 'crossed':
|
if world.worlds[player].options.entrance_shuffle == 'crossed':
|
||||||
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
||||||
if world.shuffle_ganon:
|
if world.shuffle_ganon:
|
||||||
world.random.shuffle(hole_targets)
|
world.random.shuffle(hole_targets)
|
||||||
exit, target = hole_targets.pop()
|
exit, target = hole_targets.pop()
|
||||||
connect_two_way(world, 'Pyramid Entrance', exit, player)
|
connect_two_way(world, 'Pyramid Entrance', exit, player)
|
||||||
connect_entrance(world, 'Pyramid Hole', target, player)
|
connect_entrance(world, 'Pyramid Hole', target, player)
|
||||||
if world.entrance_shuffle[player] != 'crossed':
|
if world.worlds[player].options.entrance_shuffle != 'crossed':
|
||||||
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
||||||
|
|
||||||
world.random.shuffle(hole_targets)
|
world.random.shuffle(hole_targets)
|
||||||
@@ -1400,14 +1400,14 @@ def scramble_inverted_holes(world, player):
|
|||||||
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
|
hole_targets.append(('Hyrule Castle Secret Entrance Exit', 'Hyrule Castle Secret Entrance'))
|
||||||
|
|
||||||
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed
|
# do not shuffle sanctuary into pyramid hole unless shuffle is crossed
|
||||||
if world.entrance_shuffle[player] == 'crossed':
|
if world.worlds[player].options.entrance_shuffle == 'crossed':
|
||||||
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
||||||
if world.shuffle_ganon:
|
if world.shuffle_ganon:
|
||||||
world.random.shuffle(hole_targets)
|
world.random.shuffle(hole_targets)
|
||||||
exit, target = hole_targets.pop()
|
exit, target = hole_targets.pop()
|
||||||
connect_two_way(world, 'Inverted Pyramid Entrance', exit, player)
|
connect_two_way(world, 'Inverted Pyramid Entrance', exit, player)
|
||||||
connect_entrance(world, 'Inverted Pyramid Hole', target, player)
|
connect_entrance(world, 'Inverted Pyramid Hole', target, player)
|
||||||
if world.entrance_shuffle[player] != 'crossed':
|
if world.worlds[player].options.entrance_shuffle != 'crossed':
|
||||||
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
hole_targets.append(('Sanctuary Exit', 'Sewer Drop'))
|
||||||
|
|
||||||
world.random.shuffle(hole_targets)
|
world.random.shuffle(hole_targets)
|
||||||
@@ -1430,15 +1430,15 @@ def connect_random(world, exitlist, targetlist, player, two_way=False):
|
|||||||
def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
|
def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
|
||||||
|
|
||||||
# Keeps track of entrances that cannot be used to access each exit / cave
|
# Keeps track of entrances that cannot be used to access each exit / cave
|
||||||
if world.mode[player] == 'inverted':
|
if world.worlds[player].options.mode == 'inverted':
|
||||||
invalid_connections = Inverted_Must_Exit_Invalid_Connections.copy()
|
invalid_connections = Inverted_Must_Exit_Invalid_Connections.copy()
|
||||||
else:
|
else:
|
||||||
invalid_connections = Must_Exit_Invalid_Connections.copy()
|
invalid_connections = Must_Exit_Invalid_Connections.copy()
|
||||||
invalid_cave_connections = defaultdict(set)
|
invalid_cave_connections = defaultdict(set)
|
||||||
|
|
||||||
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
if world.worlds[player].options.glitches_required in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
|
||||||
from . import OverworldGlitchRules
|
from . import OverworldGlitchRules
|
||||||
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'):
|
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.worlds[player].options.mode == 'inverted'):
|
||||||
invalid_connections[entrance] = set()
|
invalid_connections[entrance] = set()
|
||||||
if entrance in must_be_exits:
|
if entrance in must_be_exits:
|
||||||
must_be_exits.remove(entrance)
|
must_be_exits.remove(entrance)
|
||||||
@@ -1449,7 +1449,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
|
|||||||
world.random.shuffle(caves)
|
world.random.shuffle(caves)
|
||||||
|
|
||||||
# Handle inverted Aga Tower - if it depends on connections, then so does Hyrule Castle Ledge
|
# Handle inverted Aga Tower - if it depends on connections, then so does Hyrule Castle Ledge
|
||||||
if world.mode[player] == 'inverted':
|
if world.worlds[player].options.mode == 'inverted':
|
||||||
for entrance in invalid_connections:
|
for entrance in invalid_connections:
|
||||||
if world.get_entrance(entrance, player).connected_region == world.get_region('Inverted Agahnims Tower',
|
if world.get_entrance(entrance, player).connected_region == world.get_region('Inverted Agahnims Tower',
|
||||||
player):
|
player):
|
||||||
@@ -1490,7 +1490,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
|
|||||||
entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit])
|
entrance = next(e for e in entrances[::-1] if e not in invalid_connections[exit])
|
||||||
cave_entrances.append(entrance)
|
cave_entrances.append(entrance)
|
||||||
entrances.remove(entrance)
|
entrances.remove(entrance)
|
||||||
connect_two_way(world,entrance,cave_exit, player)
|
connect_two_way(world, entrance, cave_exit, player)
|
||||||
if entrance not in invalid_connections:
|
if entrance not in invalid_connections:
|
||||||
invalid_connections[exit] = set()
|
invalid_connections[exit] = set()
|
||||||
if all(entrance in invalid_connections for entrance in cave_entrances):
|
if all(entrance in invalid_connections for entrance in cave_entrances):
|
||||||
@@ -1564,7 +1564,7 @@ def simple_shuffle_dungeons(world, player):
|
|||||||
dungeon_entrances = ['Eastern Palace', 'Tower of Hera', 'Thieves Town', 'Skull Woods Final Section', 'Palace of Darkness', 'Ice Palace', 'Misery Mire', 'Swamp Palace']
|
dungeon_entrances = ['Eastern Palace', 'Tower of Hera', 'Thieves Town', 'Skull Woods Final Section', 'Palace of Darkness', 'Ice Palace', 'Misery Mire', 'Swamp Palace']
|
||||||
dungeon_exits = ['Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit', 'Skull Woods Final Section Exit', 'Palace of Darkness Exit', 'Ice Palace Exit', 'Misery Mire Exit', 'Swamp Palace Exit']
|
dungeon_exits = ['Eastern Palace Exit', 'Tower of Hera Exit', 'Thieves Town Exit', 'Skull Woods Final Section Exit', 'Palace of Darkness Exit', 'Ice Palace Exit', 'Misery Mire Exit', 'Swamp Palace Exit']
|
||||||
|
|
||||||
if world.mode[player] != 'inverted':
|
if world.worlds[player].options.mode != 'inverted':
|
||||||
if not world.shuffle_ganon:
|
if not world.shuffle_ganon:
|
||||||
connect_two_way(world, 'Ganons Tower', 'Ganons Tower Exit', player)
|
connect_two_way(world, 'Ganons Tower', 'Ganons Tower Exit', player)
|
||||||
else:
|
else:
|
||||||
@@ -1579,13 +1579,13 @@ def simple_shuffle_dungeons(world, player):
|
|||||||
|
|
||||||
# mix up 4 door dungeons
|
# mix up 4 door dungeons
|
||||||
multi_dungeons = ['Desert', 'Turtle Rock']
|
multi_dungeons = ['Desert', 'Turtle Rock']
|
||||||
if world.mode[player] == 'open' or (world.mode[player] == 'inverted' and world.shuffle_ganon):
|
if world.worlds[player].options.mode == 'open' or (world.worlds[player].options.mode == 'inverted' and world.shuffle_ganon):
|
||||||
multi_dungeons.append('Hyrule Castle')
|
multi_dungeons.append('Hyrule Castle')
|
||||||
world.random.shuffle(multi_dungeons)
|
world.random.shuffle(multi_dungeons)
|
||||||
|
|
||||||
dp_target = multi_dungeons[0]
|
dp_target = multi_dungeons[0]
|
||||||
tr_target = multi_dungeons[1]
|
tr_target = multi_dungeons[1]
|
||||||
if world.mode[player] not in ['open', 'inverted'] or (world.mode[player] == 'inverted' and world.shuffle_ganon is False):
|
if world.worlds[player].options.mode not in ['open', 'inverted'] or (world.worlds[player].options.mode == 'inverted' and world.shuffle_ganon is False):
|
||||||
# place hyrule castle as intended
|
# place hyrule castle as intended
|
||||||
hc_target = 'Hyrule Castle'
|
hc_target = 'Hyrule Castle'
|
||||||
else:
|
else:
|
||||||
@@ -1593,7 +1593,7 @@ def simple_shuffle_dungeons(world, player):
|
|||||||
|
|
||||||
# ToDo improve this?
|
# ToDo improve this?
|
||||||
|
|
||||||
if world.mode[player] != 'inverted':
|
if world.worlds[player].options.mode != 'inverted':
|
||||||
if hc_target == 'Hyrule Castle':
|
if hc_target == 'Hyrule Castle':
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (East)', 'Hyrule Castle Exit (East)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (East)', 'Hyrule Castle Exit (East)', player)
|
||||||
@@ -1708,7 +1708,7 @@ def crossed_shuffle_dungeons(world, player: int):
|
|||||||
dungeon_entrances.append('Ganons Tower')
|
dungeon_entrances.append('Ganons Tower')
|
||||||
dungeon_exits.append('Ganons Tower Exit')
|
dungeon_exits.append('Ganons Tower Exit')
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
# must connect front of hyrule castle to do escape
|
# must connect front of hyrule castle to do escape
|
||||||
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
connect_two_way(world, 'Hyrule Castle Entrance (South)', 'Hyrule Castle Exit (South)', player)
|
||||||
else:
|
else:
|
||||||
@@ -1718,7 +1718,7 @@ def crossed_shuffle_dungeons(world, player: int):
|
|||||||
connect_mandatory_exits(world, dungeon_entrances, dungeon_exits,
|
connect_mandatory_exits(world, dungeon_entrances, dungeon_exits,
|
||||||
LW_Dungeon_Entrances_Must_Exit + DW_Dungeon_Entrances_Must_Exit, player)
|
LW_Dungeon_Entrances_Must_Exit + DW_Dungeon_Entrances_Must_Exit, player)
|
||||||
|
|
||||||
if world.mode[player] == 'standard':
|
if world.worlds[player].options.mode == 'standard':
|
||||||
connect_caves(world, dungeon_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
connect_caves(world, dungeon_entrances, [], [('Hyrule Castle Exit (West)', 'Hyrule Castle Exit (East)')], player)
|
||||||
|
|
||||||
connect_caves(world, dungeon_entrances, [], dungeon_exits, player)
|
connect_caves(world, dungeon_entrances, [], dungeon_exits, player)
|
||||||
@@ -1823,14 +1823,14 @@ lookup = {
|
|||||||
|
|
||||||
|
|
||||||
def plando_connect(world, player: int):
|
def plando_connect(world, player: int):
|
||||||
if world.plando_connections[player]:
|
if world.worlds[player].options.plando_connections:
|
||||||
for connection in world.plando_connections[player]:
|
for connection in world.worlds[player].options.plando_connections:
|
||||||
func = lookup[connection.direction]
|
func = lookup[connection.direction]
|
||||||
try:
|
try:
|
||||||
func(world, connection.entrance, connection.exit, player)
|
func(world, connection.entrance, connection.exit, player)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Could not connect using {connection}") from e
|
raise Exception(f"Could not connect using {connection}") from e
|
||||||
if world.mode[player] != 'inverted':
|
if world.worlds[player].options.mode != 'inverted':
|
||||||
mark_light_world_regions(world, player)
|
mark_light_world_regions(world, player)
|
||||||
else:
|
else:
|
||||||
mark_dark_world_regions(world, player)
|
mark_dark_world_regions(world, player)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user