mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
1 Commits
plando-ite
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2404e18c03 |
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -21,6 +21,7 @@
|
|||||||
- '!data/**'
|
- '!data/**'
|
||||||
- '!.run/**'
|
- '!.run/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
|
- '!worlds_disabled/**'
|
||||||
- '!worlds/**'
|
- '!worlds/**'
|
||||||
- '!WebHost.py'
|
- '!WebHost.py'
|
||||||
- '!WebHostLib/**'
|
- '!WebHostLib/**'
|
||||||
|
|||||||
1
.github/pyright-config.json
vendored
1
.github/pyright-config.json
vendored
@@ -2,7 +2,6 @@
|
|||||||
"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 --ignore F824 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --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,17 +21,12 @@ 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 and releases may still be built and signed by hand
|
build-win: # RCs will 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
|
||||||
@@ -70,18 +65,6 @@ 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: |
|
||||||
@@ -116,8 +99,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-ubuntu2204:
|
build-ubuntu2004:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -149,7 +132,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<3.51.0" charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject 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`"
|
||||||
@@ -159,16 +142,6 @@ 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@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
- uses: ilammy/msvc-dev-cmd@v1
|
||||||
if: startsWith(matrix.os,'windows')
|
if: startsWith(matrix.os,'windows')
|
||||||
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
- uses: Bacondish2023/setup-googletest@v1
|
||||||
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,11 +11,6 @@ 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
|
||||||
@@ -31,79 +26,11 @@ 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-win:
|
build-release-ubuntu2004:
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-20.04
|
||||||
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
|
||||||
@@ -137,7 +64,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<3.51.0" charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject 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`"
|
||||||
@@ -147,14 +74,6 @@ 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,13 +4,11 @@
|
|||||||
*_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
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import sys
|
|
||||||
from worlds.ahit.Client import launch
|
from worlds.ahit.Client import launch
|
||||||
import Utils
|
import Utils
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
@@ -6,4 +5,4 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||||
launch(*sys.argv[1:])
|
launch()
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
167
BaseClasses.py
167
BaseClasses.py
@@ -9,9 +9,8 @@ from argparse import Namespace
|
|||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||||
import dataclasses
|
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -55,21 +54,12 @@ class HasNameAndPlayer(Protocol):
|
|||||||
player: int
|
player: int
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class PlandoItemBlock:
|
|
||||||
player: int
|
|
||||||
from_pool: bool
|
|
||||||
force: bool | Literal["silent"]
|
|
||||||
worlds: set[int] = dataclasses.field(default_factory=set)
|
|
||||||
items: list[str] = dataclasses.field(default_factory=list)
|
|
||||||
locations: list[str] = dataclasses.field(default_factory=list)
|
|
||||||
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
|
||||||
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
|
plando_texts: List[Dict[str, str]]
|
||||||
|
plando_items: List[List[Dict[str, Any]]]
|
||||||
|
plando_connections: List
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, "AutoWorld.World"]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: RegionManager
|
||||||
@@ -93,8 +83,6 @@ class MultiWorld():
|
|||||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||||
item_links: Dict[int, Options.ItemLinks]
|
item_links: Dict[int, Options.ItemLinks]
|
||||||
|
|
||||||
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
|
||||||
|
|
||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
@@ -172,12 +160,13 @@ class MultiWorld():
|
|||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
self.indirect_connections = {}
|
self.indirect_connections = {}
|
||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
self.plando_item_blocks = {}
|
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_item_blocks', [])
|
set_player_attr('plando_items', [])
|
||||||
|
set_player_attr('plando_texts', {})
|
||||||
|
set_player_attr('plando_connections', [])
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "Archipelago")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
@@ -234,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.", True)
|
f"Please use `self.options.{option_key}` instead.")
|
||||||
option.update(getattr(args, option_key, {}))
|
option.update(getattr(args, option_key, {}))
|
||||||
setattr(self, option_key, option)
|
setattr(self, option_key, option)
|
||||||
|
|
||||||
@@ -438,8 +427,7 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
||||||
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
|
||||||
cached = getattr(self, "_all_state", None)
|
cached = getattr(self, "_all_state", None)
|
||||||
if use_cache and cached:
|
if use_cache and cached:
|
||||||
return cached.copy()
|
return cached.copy()
|
||||||
@@ -448,13 +436,11 @@ class MultiWorld():
|
|||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
if collect_pre_fill_items:
|
for player in self.player_ids:
|
||||||
for player in self.player_ids:
|
subworld = self.worlds[player]
|
||||||
subworld = self.worlds[player]
|
for item in subworld.get_pre_fill_items():
|
||||||
for item in subworld.get_pre_fill_items():
|
subworld.collect(ret, item)
|
||||||
subworld.collect(ret, item)
|
ret.sweep_for_advancements()
|
||||||
if perform_sweep:
|
|
||||||
ret.sweep_for_advancements()
|
|
||||||
|
|
||||||
if use_cache:
|
if use_cache:
|
||||||
self._all_state = ret
|
self._all_state = ret
|
||||||
@@ -559,9 +545,7 @@ class MultiWorld():
|
|||||||
else:
|
else:
|
||||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||||
|
|
||||||
def can_beat_game(self,
|
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
||||||
starting_state: Optional[CollectionState] = None,
|
|
||||||
locations: Optional[Iterable[Location]] = None) -> bool:
|
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -570,9 +554,7 @@ class MultiWorld():
|
|||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
prog_locations = {location for location in self.get_locations() if location.item
|
||||||
base_locations = self.get_locations() if locations is None else locations
|
|
||||||
prog_locations = {location for location in base_locations if location.item
|
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
and location.item.advancement and location not in state.locations_checked}
|
||||||
|
|
||||||
while prog_locations:
|
while prog_locations:
|
||||||
@@ -634,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 and type(location.address) is int:
|
if type(location.item.code) is int:
|
||||||
locations.add(location)
|
locations.add(location)
|
||||||
else:
|
else:
|
||||||
events.add(location)
|
events.add(location)
|
||||||
@@ -741,7 +723,6 @@ class CollectionState():
|
|||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||||
assert parent.worlds, "CollectionState created without worlds initialized in parent"
|
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -1018,17 +999,6 @@ class CollectionState():
|
|||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def add_item(self, item: str, player: int, count: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
Adds the item to state.
|
|
||||||
|
|
||||||
:param item: The item to be added.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to add.
|
|
||||||
"""
|
|
||||||
assert count > 0
|
|
||||||
self.prog_items[player][item] += count
|
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
@@ -1037,33 +1007,6 @@ class CollectionState():
|
|||||||
self.blocked_connections[item.player] = set()
|
self.blocked_connections[item.player] = set()
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
def remove_item(self, item: str, player: int, count: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
Removes the item from state.
|
|
||||||
|
|
||||||
:param item: The item to be removed.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to remove.
|
|
||||||
"""
|
|
||||||
assert count > 0
|
|
||||||
self.prog_items[player][item] -= count
|
|
||||||
if self.prog_items[player][item] < 1:
|
|
||||||
del (self.prog_items[player][item])
|
|
||||||
|
|
||||||
def set_item(self, item: str, player: int, count: int) -> None:
|
|
||||||
"""
|
|
||||||
Sets the item in state equal to the provided count.
|
|
||||||
|
|
||||||
:param item: The item to modify.
|
|
||||||
:param player: The player the item is for.
|
|
||||||
:param count: How many of the item to now have.
|
|
||||||
"""
|
|
||||||
assert count >= 0
|
|
||||||
if count == 0:
|
|
||||||
del (self.prog_items[player][item])
|
|
||||||
else:
|
|
||||||
self.prog_items[player][item] = count
|
|
||||||
|
|
||||||
|
|
||||||
class EntranceType(IntEnum):
|
class EntranceType(IntEnum):
|
||||||
ONE_WAY = 1
|
ONE_WAY = 1
|
||||||
@@ -1079,6 +1022,9 @@ 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:
|
||||||
@@ -1097,8 +1043,10 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region) -> None:
|
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> 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:
|
||||||
@@ -1158,9 +1106,6 @@ 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()
|
||||||
@@ -1255,48 +1200,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -1407,6 +1310,9 @@ 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)
|
||||||
|
|
||||||
@@ -1510,10 +1416,6 @@ 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
|
||||||
@@ -1607,19 +1509,21 @@ class Spoiler:
|
|||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
required_locations = {location for sphere in collection_spheres for location in sphere}
|
restore_later: Dict[Location, Item] = {}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the location from required_locations to sweep from, and check if the game is still beatable
|
# we remove the item at location and check if game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
location.item.player)
|
location.item.player)
|
||||||
required_locations.remove(location)
|
old_item = location.item
|
||||||
if multiworld.can_beat_game(state_cache[num], required_locations):
|
location.item = None
|
||||||
|
if multiworld.can_beat_game(state_cache[num]):
|
||||||
to_delete.add(location)
|
to_delete.add(location)
|
||||||
|
restore_later[location] = old_item
|
||||||
else:
|
else:
|
||||||
# still required, got to keep it around
|
# still required, got to keep it around
|
||||||
required_locations.add(location)
|
location.item = old_item
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
@@ -1636,7 +1540,7 @@ class Spoiler:
|
|||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
precollected_items.remove(item)
|
precollected_items.remove(item)
|
||||||
multiworld.state.remove(item)
|
multiworld.state.remove(item)
|
||||||
if not multiworld.can_beat_game(multiworld.state, required_locations):
|
if not multiworld.can_beat_game():
|
||||||
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
else:
|
else:
|
||||||
@@ -1678,6 +1582,9 @@ class Spoiler:
|
|||||||
self.create_paths(state, collection_spheres)
|
self.create_paths(state, collection_spheres)
|
||||||
|
|
||||||
# repair the multiworld again
|
# repair the multiworld again
|
||||||
|
for location, item in restore_later.items():
|
||||||
|
location.item = item
|
||||||
|
|
||||||
for item in removed_precollected:
|
for item in removed_precollected:
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
|
|
||||||
|
|||||||
121
CommonClient.py
121
CommonClient.py
@@ -196,11 +196,25 @@ 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:
|
||||||
@@ -240,6 +254,7 @@ 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.
|
||||||
@@ -266,71 +281,38 @@ class CommonContext:
|
|||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
"""Slot Info from the server for the current connection"""
|
server_address: typing.Optional[str]
|
||||||
server_address: str | None
|
password: typing.Optional[str]
|
||||||
"""Autoconnect address provided by the ctx constructor"""
|
hint_cost: typing.Optional[int]
|
||||||
password: str | None
|
hint_points: typing.Optional[int]
|
||||||
"""Password used for Connecting, expected by server_auth"""
|
player_names: typing.Dict[int, str]
|
||||||
hint_cost: int | None
|
|
||||||
"""Current Hint Cost per Hint from the server"""
|
|
||||||
hint_points: int | None
|
|
||||||
"""Current avaliable Hint Points from the server"""
|
|
||||||
player_names: dict[int, str]
|
|
||||||
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
|
||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
"""
|
|
||||||
Bool to signal that status should be updated to Goal after reconnecting
|
|
||||||
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
|
||||||
"""
|
|
||||||
ready: bool
|
ready: bool
|
||||||
"""Bool to keep track of state for the /ready command"""
|
team: typing.Optional[int]
|
||||||
team: int | None
|
slot: typing.Optional[int]
|
||||||
"""Team number of currently connected slot"""
|
auth: typing.Optional[str]
|
||||||
slot: int | None
|
seed_name: typing.Optional[str]
|
||||||
"""Slot number of currently connected slot"""
|
|
||||||
auth: str | None
|
|
||||||
"""Name used in Connect packet"""
|
|
||||||
seed_name: str | None
|
|
||||||
"""Seed name that will be validated on opening a socket if present"""
|
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: set[int]
|
locations_checked: typing.Set[int] # local state
|
||||||
"""
|
locations_scouted: typing.Set[int]
|
||||||
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
items_received: typing.List[NetworkItem]
|
||||||
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
missing_locations: typing.Set[int] # server state
|
||||||
"""
|
checked_locations: typing.Set[int] # server state
|
||||||
locations_scouted: set[int]
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
"""
|
locations_info: typing.Dict[int, NetworkItem]
|
||||||
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
|
||||||
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
|
||||||
"""
|
|
||||||
items_received: list[NetworkItem]
|
|
||||||
"""List of NetworkItems recieved from the server"""
|
|
||||||
missing_locations: set[int]
|
|
||||||
"""Container of Locations that are unchecked per server state"""
|
|
||||||
checked_locations: set[int]
|
|
||||||
"""Container of Locations that are checked per server state"""
|
|
||||||
server_locations: set[int]
|
|
||||||
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
|
||||||
locations_info: dict[int, NetworkItem]
|
|
||||||
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
stored_data: dict[str, typing.Any]
|
stored_data: typing.Dict[str, typing.Any]
|
||||||
"""
|
stored_data_notification_keys: typing.Set[str]
|
||||||
Data Storage values by key that were retrieved from the server
|
|
||||||
any keys subscribed to with SetNotify will be kept up to date
|
|
||||||
"""
|
|
||||||
stored_data_notification_keys: set[str]
|
|
||||||
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
|
# current message box through kvui
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
"""Current message box through kvui"""
|
# message box reporting a loss of connection
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
"""Message box reporting a loss of connection"""
|
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||||
# server state
|
# server state
|
||||||
@@ -374,6 +356,7 @@ 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)
|
||||||
@@ -430,8 +413,7 @@ 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
|
||||||
if self.ui:
|
self.ui.update_hints()
|
||||||
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 """
|
||||||
@@ -588,6 +570,7 @@ 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."""
|
||||||
@@ -596,26 +579,33 @@ 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_data_package_checksums:
|
if game not in remote_date_package_versions and 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 not remote_checksum: # custom data package and no checksum for this game
|
if remote_version == 0 and 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 remote_checksum != cached_checksum:
|
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
||||||
|
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 == local_checksum:
|
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
||||||
|
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 remote_checksum != cache_checksum:
|
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||||
|
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)
|
||||||
@@ -625,6 +615,7 @@ 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):
|
||||||
@@ -633,6 +624,9 @@ 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)
|
||||||
@@ -895,8 +889,9 @@ 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_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -1133,7 +1128,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.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
267
FF1Client.py
Normal file
267
FF1Client.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from asyncio import StreamReader, StreamWriter
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
from Utils import async_start
|
||||||
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
|
get_base_parser
|
||||||
|
|
||||||
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
||||||
|
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
||||||
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
||||||
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
|
|
||||||
|
DISPLAY_MSGS = True
|
||||||
|
|
||||||
|
|
||||||
|
class FF1CommandProcessor(ClientCommandProcessor):
|
||||||
|
def __init__(self, ctx: CommonContext):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
def _cmd_nes(self):
|
||||||
|
"""Check NES Connection State"""
|
||||||
|
if isinstance(self.ctx, FF1Context):
|
||||||
|
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||||
|
|
||||||
|
def _cmd_toggle_msgs(self):
|
||||||
|
"""Toggle displaying messages in EmuHawk"""
|
||||||
|
global DISPLAY_MSGS
|
||||||
|
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||||
|
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||||
|
|
||||||
|
|
||||||
|
class FF1Context(CommonContext):
|
||||||
|
command_processor = FF1CommandProcessor
|
||||||
|
game = 'Final Fantasy'
|
||||||
|
items_handling = 0b111 # full remote
|
||||||
|
|
||||||
|
def __init__(self, server_address, password):
|
||||||
|
super().__init__(server_address, password)
|
||||||
|
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||||
|
self.nes_sync_task = None
|
||||||
|
self.messages = {}
|
||||||
|
self.locations_array = None
|
||||||
|
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||||
|
self.awaiting_rom = False
|
||||||
|
self.display_msgs = True
|
||||||
|
|
||||||
|
async def server_auth(self, password_requested: bool = False):
|
||||||
|
if password_requested and not self.password:
|
||||||
|
await super(FF1Context, self).server_auth(password_requested)
|
||||||
|
if not self.auth:
|
||||||
|
self.awaiting_rom = True
|
||||||
|
logger.info('Awaiting connection to NES to get Player information')
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send_connect()
|
||||||
|
|
||||||
|
def _set_message(self, msg: str, msg_id: int):
|
||||||
|
if DISPLAY_MSGS:
|
||||||
|
self.messages[time.time(), msg_id] = msg
|
||||||
|
|
||||||
|
def on_package(self, cmd: str, args: dict):
|
||||||
|
if cmd == 'Connected':
|
||||||
|
async_start(parse_locations(self.locations_array, self, True))
|
||||||
|
elif cmd == 'Print':
|
||||||
|
msg = args['text']
|
||||||
|
if ': !' not in msg:
|
||||||
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
|
|
||||||
|
def on_print_json(self, args: dict):
|
||||||
|
if self.ui:
|
||||||
|
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||||
|
else:
|
||||||
|
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||||
|
logger.info(text)
|
||||||
|
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||||
|
if relevant:
|
||||||
|
item = args["item"]
|
||||||
|
# goes to this world
|
||||||
|
if self.slot_concerns_self(args["receiving"]):
|
||||||
|
relevant = True
|
||||||
|
# found in this world
|
||||||
|
elif self.slot_concerns_self(item.player):
|
||||||
|
relevant = True
|
||||||
|
# not related
|
||||||
|
else:
|
||||||
|
relevant = False
|
||||||
|
if relevant:
|
||||||
|
item = args["item"]
|
||||||
|
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||||
|
self._set_message(msg, item.item)
|
||||||
|
|
||||||
|
def run_gui(self):
|
||||||
|
from kvui import GameManager
|
||||||
|
|
||||||
|
class FF1Manager(GameManager):
|
||||||
|
logging_pairs = [
|
||||||
|
("Client", "Archipelago")
|
||||||
|
]
|
||||||
|
base_title = "Archipelago Final Fantasy 1 Client"
|
||||||
|
|
||||||
|
self.ui = FF1Manager(self)
|
||||||
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
|
||||||
|
def get_payload(ctx: FF1Context):
|
||||||
|
current_time = time.time()
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"items": [item.item for item in ctx.items_received],
|
||||||
|
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||||
|
if key[0] > current_time - 10}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
||||||
|
if locations_array == ctx.locations_array and not force:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# print("New values")
|
||||||
|
ctx.locations_array = locations_array
|
||||||
|
locations_checked = []
|
||||||
|
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||||
|
await ctx.send_msgs([
|
||||||
|
{"cmd": "StatusUpdate",
|
||||||
|
"status": 30}
|
||||||
|
])
|
||||||
|
ctx.finished_game = True
|
||||||
|
for location in ctx.missing_locations:
|
||||||
|
# index will be - 0x100 or 0x200
|
||||||
|
index = location
|
||||||
|
if location < 0x200:
|
||||||
|
# Location is a chest
|
||||||
|
index -= 0x100
|
||||||
|
flag = 0x04
|
||||||
|
else:
|
||||||
|
# Location is an NPC
|
||||||
|
index -= 0x200
|
||||||
|
flag = 0x02
|
||||||
|
|
||||||
|
# print(f"Location: {ctx.location_names[location]}")
|
||||||
|
# print(f"Index: {str(hex(index))}")
|
||||||
|
# print(f"value: {locations_array[index] & flag != 0}")
|
||||||
|
if locations_array[index] & flag != 0:
|
||||||
|
locations_checked.append(location)
|
||||||
|
if locations_checked:
|
||||||
|
# print([ctx.location_names[location] for location in locations_checked])
|
||||||
|
await ctx.send_msgs([
|
||||||
|
{"cmd": "LocationChecks",
|
||||||
|
"locations": locations_checked}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
async def nes_sync_task(ctx: FF1Context):
|
||||||
|
logger.info("Starting nes connector. Use /nes for status information")
|
||||||
|
while not ctx.exit_event.is_set():
|
||||||
|
error_status = None
|
||||||
|
if ctx.nes_streams:
|
||||||
|
(reader, writer) = ctx.nes_streams
|
||||||
|
msg = get_payload(ctx).encode()
|
||||||
|
writer.write(msg)
|
||||||
|
writer.write(b'\n')
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||||
|
try:
|
||||||
|
# Data will return a dict with up to two fields:
|
||||||
|
# 1. A keepalive response of the Players Name (always)
|
||||||
|
# 2. An array representing the memory values of the locations area (if in game)
|
||||||
|
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||||
|
data_decoded = json.loads(data.decode())
|
||||||
|
# print(data_decoded)
|
||||||
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
|
# Not just a keep alive ping, parse
|
||||||
|
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||||
|
if not ctx.auth:
|
||||||
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
|
if ctx.auth == '':
|
||||||
|
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||||
|
"the ROM using the same link but adding your slot name")
|
||||||
|
if ctx.awaiting_rom:
|
||||||
|
await ctx.server_auth(False)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("Read Timed Out, Reconnecting")
|
||||||
|
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.nes_streams = None
|
||||||
|
except ConnectionResetError as e:
|
||||||
|
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.nes_streams = None
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug("Connection Timed Out, Reconnecting")
|
||||||
|
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.nes_streams = None
|
||||||
|
except ConnectionResetError:
|
||||||
|
logger.debug("Connection Lost, Reconnecting")
|
||||||
|
error_status = CONNECTION_RESET_STATUS
|
||||||
|
writer.close()
|
||||||
|
ctx.nes_streams = None
|
||||||
|
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||||
|
if not error_status:
|
||||||
|
logger.info("Successfully Connected to NES")
|
||||||
|
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||||
|
else:
|
||||||
|
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||||
|
elif error_status:
|
||||||
|
ctx.nes_status = error_status
|
||||||
|
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
logger.debug("Attempting to connect to NES")
|
||||||
|
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||||
|
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug("Connection Timed Out, Trying Again")
|
||||||
|
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||||
|
continue
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
logger.debug("Connection Refused, Trying Again")
|
||||||
|
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
|
Utils.init_logging("FF1Client")
|
||||||
|
|
||||||
|
options = Utils.get_options()
|
||||||
|
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||||
|
|
||||||
|
async def main(args):
|
||||||
|
ctx = FF1Context(args.connect, args.password)
|
||||||
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
if gui_enabled:
|
||||||
|
ctx.run_gui()
|
||||||
|
ctx.run_cli()
|
||||||
|
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||||
|
|
||||||
|
await ctx.exit_event.wait()
|
||||||
|
ctx.server_address = None
|
||||||
|
|
||||||
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
if ctx.nes_sync_task:
|
||||||
|
await ctx.nes_sync_task
|
||||||
|
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
parser = get_base_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
colorama.init()
|
||||||
|
|
||||||
|
asyncio.run(main(args))
|
||||||
|
colorama.deinit()
|
||||||
12
FactorioClient.py
Normal file
12
FactorioClient.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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()
|
||||||
403
Fill.py
403
Fill.py
@@ -4,7 +4,7 @@ import logging
|
|||||||
import typing
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
@@ -75,11 +75,9 @@ 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:
|
||||||
# The items added into `reachable_items` are placed starting from the end of each deque in
|
for p, pool_item in enumerate(item_pool):
|
||||||
# `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:
|
||||||
del item_pool[-p]
|
item_pool.pop(p)
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
@@ -100,7 +98,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
perform_access_check = True
|
perform_access_check = True
|
||||||
@@ -138,21 +136,32 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
||||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
prev_state = swap_state.copy()
|
||||||
|
prev_loc_count = len(
|
||||||
|
multiworld.get_reachable_locations(prev_state))
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
swap_state.collect(item_to_place, True)
|
||||||
placed_item)
|
new_loc_count = len(
|
||||||
item_pool.append(placed_item)
|
multiworld.get_reachable_locations(swap_state))
|
||||||
|
|
||||||
# cleanup at the end to hopefully get better errors
|
if new_loc_count >= prev_loc_count:
|
||||||
cleanup_required = True
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
break
|
swap_count += 1
|
||||||
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
|
|
||||||
|
reachable_items[placed_item.player].appendleft(
|
||||||
|
placed_item)
|
||||||
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
|
# cleanup at the end to hopefully get better errors
|
||||||
|
cleanup_required = True
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
# Item can't be placed here, restore original item
|
||||||
location.item = placed_item
|
location.item = placed_item
|
||||||
@@ -231,7 +240,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
total = min(len(itempool), len(locations))
|
||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||||
@@ -332,19 +341,17 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if
|
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if
|
|
||||||
location.player in minimal_players and
|
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
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)
|
||||||
@@ -356,7 +363,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
|||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
@@ -493,15 +500,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
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
|
||||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
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)
|
||||||
@@ -509,15 +514,14 @@ 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, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, multiworld.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, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, multiworld.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, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, multiworld.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:
|
||||||
@@ -668,9 +672,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if multiworld.worlds[player].options.progression_balancing > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info("Skipping multiworld progression balancing.")
|
logging.info('Skipping multiworld progression balancing.')
|
||||||
else:
|
else:
|
||||||
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(multiworld)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
@@ -768,7 +772,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
break
|
break
|
||||||
elif not balancing_sphere:
|
elif not balancing_sphere:
|
||||||
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||||
# Gather a set of locations which we can swap items into
|
# Gather a set of locations which we can swap items into
|
||||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||||
for l in unchecked_locations:
|
for l in unchecked_locations:
|
||||||
@@ -784,8 +788,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
for location in itertools.chain((
|
for location in itertools.chain((
|
||||||
l for l in items_to_replace
|
l for l in items_to_replace
|
||||||
if l.item.player == player
|
if l.item.player == player
|
||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
@@ -858,30 +862,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
|
||||||
|
|
||||||
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
def distribute_planned(multiworld: MultiWorld) -> None:
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||||
if isinstance(force, bool):
|
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||||
logging.warning(f"{warning}")
|
logging.warning(f'{warning}')
|
||||||
else:
|
else:
|
||||||
logging.debug(f"{warning}")
|
logging.debug(f'{warning}')
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||||
if force is True:
|
if force in [True, 'fail', 'failure']:
|
||||||
raise Exception(warning)
|
raise Exception(warning)
|
||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = multiworld.state.copy()
|
||||||
|
swept_state.sweep_for_advancements()
|
||||||
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
for loc in multiworld.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc.name)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc.name)
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
world_name_lookup = multiworld.world_name_lookup
|
||||||
|
|
||||||
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||||
player_ids: set[int] = set(multiworld.player_ids)
|
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||||
|
player_ids = set(multiworld.player_ids)
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
plando_blocks[player] = []
|
for block in multiworld.plando_items[player]:
|
||||||
for block in multiworld.worlds[player].options.plando_items:
|
block['player'] = player
|
||||||
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
if 'force' not in block:
|
||||||
target_world = block.world
|
block['force'] = 'silent'
|
||||||
|
if 'from_pool' not in block:
|
||||||
|
block['from_pool'] = True
|
||||||
|
elif not isinstance(block['from_pool'], bool):
|
||||||
|
from_pool_type = type(block['from_pool'])
|
||||||
|
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
||||||
|
if 'world' not in block:
|
||||||
|
target_world = False
|
||||||
|
else:
|
||||||
|
target_world = block['world']
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or multiworld.players == 1: # target own world
|
||||||
worlds: set[int] = {player}
|
worlds: typing.Set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(multiworld.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
@@ -891,197 +917,172 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
|||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
if listed_world not in world_name_lookup:
|
if listed_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, multiworld.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
else: # target world by slot name
|
else: # target world by slot name
|
||||||
if target_world not in world_name_lookup:
|
if target_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block.force)
|
block['force'])
|
||||||
continue
|
continue
|
||||||
worlds = {world_name_lookup[target_world]}
|
worlds = {world_name_lookup[target_world]}
|
||||||
new_block.worlds = worlds
|
block['world'] = worlds
|
||||||
|
|
||||||
items: list[str] | dict[str, typing.Any] = block.items
|
items: block_value = []
|
||||||
|
if "items" in block:
|
||||||
|
items = block["items"]
|
||||||
|
if 'count' not in block:
|
||||||
|
block['count'] = False
|
||||||
|
elif "item" in block:
|
||||||
|
items = block["item"]
|
||||||
|
if 'count' not in block:
|
||||||
|
block['count'] = 1
|
||||||
|
else:
|
||||||
|
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||||
|
continue
|
||||||
if isinstance(items, dict):
|
if isinstance(items, dict):
|
||||||
item_list: list[str] = []
|
item_list: typing.List[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
new_block.items = items
|
if isinstance(items, str):
|
||||||
|
items = [items]
|
||||||
|
block['items'] = items
|
||||||
|
|
||||||
locations: list[str] = block.locations
|
locations: block_value = []
|
||||||
|
if 'location' in block:
|
||||||
|
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||||
|
elif 'locations' in block:
|
||||||
|
locations = block['locations']
|
||||||
if isinstance(locations, str):
|
if isinstance(locations, str):
|
||||||
locations = [locations]
|
locations = [locations]
|
||||||
|
|
||||||
locations_from_groups: list[str] = []
|
if isinstance(locations, dict):
|
||||||
resolved_locations: list[Location] = []
|
location_list = []
|
||||||
for target_player in worlds:
|
for key, value in locations.items():
|
||||||
world_locations = multiworld.get_unfilled_locations(target_player)
|
location_list += [key] * value
|
||||||
for group in multiworld.worlds[target_player].location_name_groups:
|
locations = location_list
|
||||||
if group in locations:
|
|
||||||
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
|
||||||
resolved_locations.extend(location for location in world_locations
|
|
||||||
if location.name in [*locations, *locations_from_groups])
|
|
||||||
new_block.locations = sorted(dict.fromkeys(locations))
|
|
||||||
new_block.resolved_locations = sorted(set(resolved_locations))
|
|
||||||
|
|
||||||
count = block.count
|
|
||||||
if not count:
|
|
||||||
count = len(new_block.items)
|
|
||||||
if isinstance(count, int):
|
|
||||||
count = {"min": count, "max": count}
|
|
||||||
if "min" not in count:
|
|
||||||
count["min"] = 0
|
|
||||||
if "max" not in count:
|
|
||||||
count["max"] = len(new_block.items)
|
|
||||||
|
|
||||||
new_block.count = count
|
|
||||||
plando_blocks[player].append(new_block)
|
|
||||||
|
|
||||||
return plando_blocks
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
|
||||||
if isinstance(force, bool):
|
|
||||||
logging.warning(f"{warning}")
|
|
||||||
else:
|
|
||||||
logging.debug(f"{warning}")
|
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
|
||||||
if force is True:
|
|
||||||
raise Exception(warning)
|
|
||||||
else:
|
|
||||||
warn(warning, force)
|
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
|
||||||
swept_state.sweep_for_advancements()
|
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
|
||||||
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
|
||||||
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
|
||||||
for loc in multiworld.get_unfilled_locations():
|
|
||||||
if loc in reachable:
|
|
||||||
early_locations[loc.player].append(loc)
|
|
||||||
else: # not reachable with swept state
|
|
||||||
non_early_locations[loc.player].append(loc)
|
|
||||||
|
|
||||||
for player in multiworld.plando_item_blocks:
|
|
||||||
removed = []
|
|
||||||
for block in multiworld.plando_item_blocks[player]:
|
|
||||||
locations = block.locations
|
|
||||||
resolved_locations = block.resolved_locations
|
|
||||||
worlds = block.worlds
|
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
|
locations.remove("early_locations")
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
resolved_locations += early_locations[target_player]
|
locations += early_locations[target_player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
|
locations.remove("non_early_locations")
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
resolved_locations += non_early_locations[target_player]
|
locations += non_early_locations[target_player]
|
||||||
|
|
||||||
if block.count["max"] > len(block.items):
|
block['locations'] = list(dict.fromkeys(locations))
|
||||||
count = block.count["max"]
|
|
||||||
failed(f"Plando count {count} greater than items specified", block.force)
|
|
||||||
block.count["max"] = len(block.items)
|
|
||||||
if block.count["min"] > len(block.items):
|
|
||||||
block.count["min"] = len(block.items)
|
|
||||||
if block.count["max"] > len(block.resolved_locations) > 0:
|
|
||||||
count = block.count["max"]
|
|
||||||
failed(f"Plando count {count} greater than locations specified", block.force)
|
|
||||||
block.count["max"] = len(block.resolved_locations)
|
|
||||||
if block.count["min"] > len(block.resolved_locations):
|
|
||||||
block.count["min"] = len(block.resolved_locations)
|
|
||||||
block.count["target"] = multiworld.random.randint(block.count["min"],
|
|
||||||
block.count["max"])
|
|
||||||
|
|
||||||
if not block.count["target"]:
|
if not block['count']:
|
||||||
removed.append(block)
|
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||||
|
len(block['locations']) > 0 else len(block['items']))
|
||||||
|
if isinstance(block['count'], int):
|
||||||
|
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||||
|
if 'min' not in block['count']:
|
||||||
|
block['count']['min'] = 0
|
||||||
|
if 'max' not in block['count']:
|
||||||
|
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||||
|
len(block['locations']) > 0 else len(block['items']))
|
||||||
|
if block['count']['max'] > len(block['items']):
|
||||||
|
count = block['count']
|
||||||
|
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||||
|
block['count'] = len(block['items'])
|
||||||
|
if block['count']['max'] > len(block['locations']) > 0:
|
||||||
|
count = block['count']
|
||||||
|
failed(f"Plando count {count} greater than locations specified", block['force'])
|
||||||
|
block['count'] = len(block['locations'])
|
||||||
|
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
||||||
|
|
||||||
for block in removed:
|
if block['count']['target'] > 0:
|
||||||
multiworld.plando_item_blocks[player].remove(block)
|
plando_blocks.append(block)
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
|
||||||
def warn(warning: str, force: bool | str) -> None:
|
|
||||||
if isinstance(force, bool):
|
|
||||||
logging.warning(f"{warning}")
|
|
||||||
else:
|
|
||||||
logging.debug(f"{warning}")
|
|
||||||
|
|
||||||
def failed(warning: str, force: bool | str) -> None:
|
|
||||||
if force is True:
|
|
||||||
raise Exception(warning)
|
|
||||||
else:
|
|
||||||
warn(warning, force)
|
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
multiworld.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
||||||
if len(block.resolved_locations) > 0
|
if len(block['locations']) > 0
|
||||||
else len(multiworld.get_unfilled_locations(block.player)) -
|
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
||||||
block.count["target"]))
|
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement.player
|
player = placement['player']
|
||||||
try:
|
try:
|
||||||
worlds = placement.worlds
|
worlds = placement['world']
|
||||||
locations = placement.resolved_locations
|
locations = placement['locations']
|
||||||
items = placement.items
|
items = placement['items']
|
||||||
maxcount = placement.count["target"]
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement.from_pool
|
from_pool = placement['from_pool']
|
||||||
|
|
||||||
item_candidates = []
|
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||||
if from_pool:
|
|
||||||
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
|
||||||
for item in multiworld.random.sample(items, maxcount):
|
|
||||||
candidate = next((i for i in instances if i.name == item), None)
|
|
||||||
if candidate is None:
|
|
||||||
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
|
||||||
f"it's already missing from it", placement.force)
|
|
||||||
candidate = multiworld.worlds[player].create_item(item)
|
|
||||||
else:
|
|
||||||
multiworld.itempool.remove(candidate)
|
|
||||||
instances.remove(candidate)
|
|
||||||
item_candidates.append(candidate)
|
|
||||||
else:
|
|
||||||
item_candidates = [multiworld.worlds[player].create_item(item)
|
|
||||||
for item in multiworld.random.sample(items, maxcount)]
|
|
||||||
if any(item.code is None for item in item_candidates) \
|
|
||||||
and not all(item.code is None for item in item_candidates):
|
|
||||||
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
|
||||||
f"event items and non-event items. "
|
|
||||||
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
|
||||||
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
|
||||||
placement.force)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
is_real = item_candidates[0].code is not None
|
|
||||||
candidates = [candidate for candidate in locations if candidate.item is None
|
|
||||||
and bool(candidate.address) == is_real]
|
|
||||||
multiworld.random.shuffle(candidates)
|
multiworld.random.shuffle(candidates)
|
||||||
allstate = multiworld.get_all_state(False)
|
multiworld.random.shuffle(items)
|
||||||
mincount = placement.count["min"]
|
count = 0
|
||||||
allowed_margin = len(item_candidates) - mincount
|
err: typing.List[str] = []
|
||||||
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
||||||
allow_partial=True, name="Plando Main Fill")
|
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
||||||
|
for item_name in items:
|
||||||
|
index_to_delete: typing.Optional[int] = None
|
||||||
|
if from_pool:
|
||||||
|
try:
|
||||||
|
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
||||||
|
index_to_delete, item = next(
|
||||||
|
(i, item) for i, item in enumerate(multiworld.itempool)
|
||||||
|
if item.player == player and item.name == item_name and i not in claimed_indices
|
||||||
|
)
|
||||||
|
except StopIteration:
|
||||||
|
warn(
|
||||||
|
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
||||||
|
placement['force'])
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
else:
|
||||||
|
item = multiworld.worlds[player].create_item(item_name)
|
||||||
|
|
||||||
|
for location in reversed(candidates):
|
||||||
|
if (location.address is None) == (item.code is None): # either both None or both not None
|
||||||
|
if not location.item:
|
||||||
|
if location.item_rule(item):
|
||||||
|
if location.can_fill(multiworld.state, item, False):
|
||||||
|
successful_pairs.append((index_to_delete, item, location))
|
||||||
|
claimed_indices.add(index_to_delete)
|
||||||
|
candidates.remove(location)
|
||||||
|
count = count + 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
err.append(f"Can't place item at {location} due to fill condition not met.")
|
||||||
|
else:
|
||||||
|
err.append(f"{item_name} not allowed at {location}.")
|
||||||
|
else:
|
||||||
|
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
||||||
|
else:
|
||||||
|
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
||||||
|
|
||||||
|
if count == maxcount:
|
||||||
|
break
|
||||||
|
if count < placement['count']['min']:
|
||||||
|
m = placement['count']['min']
|
||||||
|
failed(
|
||||||
|
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
||||||
|
placement['force'])
|
||||||
|
|
||||||
|
# Sort indices in reverse so we can remove them one by one
|
||||||
|
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
||||||
|
|
||||||
|
for (index, item, location) in successful_pairs:
|
||||||
|
multiworld.push_item(location, item, collect=False)
|
||||||
|
location.locked = True
|
||||||
|
logging.debug(f"Plando placed {item} at {location}")
|
||||||
|
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
||||||
|
multiworld.itempool.pop(index)
|
||||||
|
|
||||||
if len(item_candidates) > allowed_margin:
|
|
||||||
failed(f"Could not place {len(item_candidates)} "
|
|
||||||
f"of {mincount + allowed_margin} item(s) "
|
|
||||||
f"for {multiworld.player_name[player]}, "
|
|
||||||
f"remaining items: {item_candidates}",
|
|
||||||
placement.force)
|
|
||||||
if from_pool:
|
|
||||||
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||||
|
|||||||
98
Generate.py
98
Generate.py
@@ -10,8 +10,8 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, Tuple, Union
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -54,22 +54,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +67,7 @@ def get_seed_name(random_source) -> str:
|
|||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> tuple[argparse.Namespace, int]:
|
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
raise Exception("Worlds system should not be loaded before logging init.")
|
||||||
@@ -95,7 +85,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: dict[str, tuple[Any, ...]] = {}
|
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
@@ -118,8 +108,6 @@ 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):
|
||||||
@@ -176,11 +164,10 @@ 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
|
||||||
|
|
||||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
@@ -212,7 +199,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
path = player_path_cache[player]
|
path = player_path_cache[player]
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||||
for settingsObject in settings:
|
for settingsObject in settings:
|
||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
@@ -224,14 +211,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||||
|
|
||||||
# name was not specified
|
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||||
if player not in erargs.name:
|
erargs.name[player] = f"Player{player}"
|
||||||
if path == args.weights_file_path:
|
elif player not in erargs.name: # if name was not specified, generate it from filename
|
||||||
# weights file, so we need to make the name unique
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = f"Player{player}"
|
|
||||||
else:
|
|
||||||
# use the filename
|
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
@@ -246,7 +229,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
return erargs, seed
|
return erargs, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||||
try:
|
try:
|
||||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||||
@@ -256,20 +239,7 @@ 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
|
||||||
|
|
||||||
from yaml.error import MarkedYAMLError
|
return tuple(parse_yamls(yaml))
|
||||||
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:
|
||||||
@@ -309,35 +279,33 @@ 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 SafeFormatter(string.Formatter):
|
class SafeDict(dict):
|
||||||
def get_value(self, key, args, kwargs):
|
def __missing__(self, key):
|
||||||
if isinstance(key, int):
|
return '{' + key + '}'
|
||||||
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,
|
||||||
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
NUMBER=(number if number > 1 else ''),
|
||||||
"NUMBER": (number if number > 1 else ''),
|
player=player,
|
||||||
"player": player,
|
PLAYER=(player if player > 1 else '')))
|
||||||
"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
|
||||||
|
|
||||||
|
|
||||||
|
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||||
|
"""Roll a percentage chance.
|
||||||
|
percentage is expected to be in range [0, 100]"""
|
||||||
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
@@ -382,7 +350,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
@@ -403,7 +371,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
if "name" not in option_set:
|
if "name" not in option_set:
|
||||||
raise ValueError("One of your linked options does not have a name.")
|
raise ValueError("One of your linked options does not have a name.")
|
||||||
try:
|
try:
|
||||||
if Options.roll_percentage(option_set["percentage"]):
|
if roll_percentage(option_set["percentage"]):
|
||||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||||
new_options = option_set["options"]
|
new_options = option_set["options"]
|
||||||
for category_name, category_options in new_options.items():
|
for category_name, category_options in new_options.items():
|
||||||
@@ -436,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
trigger_result = get_choice("option_result", option_set)
|
trigger_result = get_choice("option_result", option_set)
|
||||||
result = get_choice(key, currently_targeted_weights)
|
result = get_choice(key, currently_targeted_weights)
|
||||||
currently_targeted_weights[key] = result
|
currently_targeted_weights[key] = result
|
||||||
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||||
for category_name, category_options in option_set["options"].items():
|
for category_name, category_options in option_set["options"].items():
|
||||||
currently_targeted_weights = weights
|
currently_targeted_weights = weights
|
||||||
if category_name:
|
if category_name:
|
||||||
@@ -467,14 +435,6 @@ 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:
|
||||||
@@ -540,6 +500,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
|
|
||||||
|
# TODO remove plando_items after moving it to the options system
|
||||||
|
valid_keys.add("plando_items")
|
||||||
|
if PlandoOptions.items in plando_options:
|
||||||
|
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
# TODO there are still more LTTP options not on the options system
|
# TODO there are still more LTTP options not on the options system
|
||||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
|
|||||||
357
Launcher.py
357
Launcher.py
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago Launcher
|
Archipelago launcher for bundled app.
|
||||||
|
|
||||||
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
* if run with APBP as argument, launch corresponding client.
|
||||||
* If run with component name as argument, run it passing argv[2:] as arguments.
|
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||||
* If run without arguments or unknown arguments, open launcher GUI.
|
* if run without arguments, open launcher GUI
|
||||||
|
|
||||||
Additional components can be added to worlds.LauncherComponents.components.
|
Scroll down to components= to add components to the launcher as well as setup.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import shlex
|
import shlex
|
||||||
@@ -16,14 +18,12 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Any
|
from typing import Callable, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -85,16 +85,12 @@ 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")
|
||||||
else:
|
|
||||||
webbrowser.open(folder_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
if exe:
|
|
||||||
subprocess.Popen([exe, folder_path])
|
subprocess.Popen([exe, folder_path])
|
||||||
else:
|
else:
|
||||||
logging.warning(f"No file browser available to open {folder_path}")
|
webbrowser.open(folder_path)
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -109,39 +105,69 @@ 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",
|
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Browse Files", func=browse_files),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
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)
|
||||||
client_components = []
|
launch_args = (path, *launch_args)
|
||||||
|
client_component = None
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
game = queries["game"][0]
|
if "game" in queries:
|
||||||
|
game = queries["game"][0]
|
||||||
|
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
||||||
|
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_components.append(component)
|
client_component = component
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
return client_components, text_client_component
|
|
||||||
|
if client_component is None:
|
||||||
|
run_component(text_client_component, *launch_args)
|
||||||
|
return
|
||||||
|
|
||||||
|
from kvui import App, Button, BoxLayout, Label, Window
|
||||||
|
|
||||||
|
class Popup(App):
|
||||||
|
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 build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
||||||
from kvui import ButtonsPrompt
|
|
||||||
component_options = {
|
|
||||||
component.display_name: component for component in component_list
|
|
||||||
}
|
|
||||||
popup = ButtonsPrompt("Connect to Multiworld",
|
|
||||||
"Select client to open and connect with.",
|
|
||||||
lambda component_name: run_component(component_options[component_name], *launch_args),
|
|
||||||
*component_options.keys())
|
|
||||||
popup.open()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -152,7 +178,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||||
if isinstance(component, str):
|
if isinstance(component, str):
|
||||||
name = component
|
name = component
|
||||||
component = None
|
component = None
|
||||||
@@ -194,189 +220,100 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
def create_shortcut(button: Any, component: Component) -> None:
|
refresh_components: Optional[Callable[[], None]] = 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: Callable[[], None] | None = None
|
def run_gui():
|
||||||
|
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
||||||
|
|
||||||
def run_gui(launch_components: list[Component], args: Any) -> None:
|
|
||||||
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.metrics import dp
|
from kivy.uix.relativelayout import RelativeLayout
|
||||||
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
|
|
||||||
|
|
||||||
from kivy.lang.builder import Builder
|
class Launcher(App):
|
||||||
|
|
||||||
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"
|
||||||
top_screen: MDFloatLayout = ObjectProperty(None)
|
container: ContainerLayout
|
||||||
navigation: MDGridLayout = ObjectProperty(None)
|
grid: GridLayout
|
||||||
grid: MDGridLayout = ObjectProperty(None)
|
_tool_layout: Optional[ScrollBox] = None
|
||||||
button_layout: ScrollBox = ObjectProperty(None)
|
_client_layout: Optional[ScrollBox] = None
|
||||||
search_box: MDTextField = ObjectProperty(None)
|
|
||||||
cards: list[LauncherCard]
|
|
||||||
current_filter: Sequence[str | Type] | None
|
|
||||||
|
|
||||||
def __init__(self, ctx=None, components=None, args=None):
|
def __init__(self, ctx=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_components = components
|
|
||||||
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 set_favorite(self, caller):
|
def _refresh_components(self) -> None:
|
||||||
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_card(self, component: Component) -> LauncherCard:
|
def build_button(component: Component) -> Widget:
|
||||||
"""
|
|
||||||
Builds a card widget for a given component.
|
|
||||||
|
|
||||||
:param component: The component associated with the button.
|
|
||||||
|
|
||||||
:return: The created Card Widget.
|
|
||||||
"""
|
"""
|
||||||
button_card = LauncherCard(component=component,
|
Builds a button widget for a given component.
|
||||||
image_path=icon_paths[component.icon])
|
|
||||||
|
|
||||||
def open_menu(caller):
|
Args:
|
||||||
caller.menu.open()
|
component (Component): The component associated with the button.
|
||||||
|
|
||||||
menu_items = [
|
Returns:
|
||||||
{
|
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)
|
||||||
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
button.component = component
|
||||||
if not type_filter:
|
button.bind(on_release=self.component_action)
|
||||||
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
if component.icon != "icon":
|
||||||
favorites = "favorites" in type_filter
|
image = ApAsyncImage(source=icon_paths[component.icon],
|
||||||
|
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.button_layout, "must call `build` first"
|
assert self._tool_layout and self._client_layout, "must call `build` first"
|
||||||
tool_children = reversed(self.button_layout.layout.children)
|
tool_children = reversed(self._tool_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self.button_layout.layout.remove_widget(child)
|
self._tool_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)
|
||||||
|
|
||||||
cards = [card for card in self.cards if card.component.type in type_filter
|
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||||
or favorites and card.component.display_name in self.favorites]
|
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||||
|
_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}
|
||||||
|
|
||||||
self.current_filter = type_filter
|
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||||
|
_tools.items(), _miscs.items(), _adjusters.items()
|
||||||
for card in cards:
|
), _clients.items()):
|
||||||
self.button_layout.layout.add_widget(card)
|
# column 1
|
||||||
|
if tool:
|
||||||
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||||
- self.button_layout.height
|
# column 2
|
||||||
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
if client:
|
||||||
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
self._client_layout.layout.add_widget(build_button(client[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.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
self.container = ContainerLayout()
|
||||||
self.grid = self.top_screen.ids.grid
|
self.grid = GridLayout(cols=2)
|
||||||
self.navigation = self.top_screen.ids.navigation
|
self.container.add_widget(self.grid)
|
||||||
self.button_layout = self.top_screen.ids.button_layout
|
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
||||||
self.search_box = self.top_screen.ids.search_box
|
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
||||||
self.set_colors()
|
self._tool_layout = ScrollBox()
|
||||||
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
self._tool_layout.layout.orientation = "vertical"
|
||||||
|
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)
|
|
||||||
|
|
||||||
for component in components:
|
return self.container
|
||||||
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_components:
|
|
||||||
build_uri_popup(self.launch_components, self.launch_args)
|
|
||||||
self.launch_components = 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:
|
||||||
@@ -388,16 +325,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {filename}")
|
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.
|
||||||
@@ -405,13 +333,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
|||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
def on_stop(self):
|
Launcher().run()
|
||||||
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(components=launch_components, 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
|
||||||
@@ -430,7 +352,7 @@ def run_component(component: Component, *args):
|
|||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
logging.warning(f"Component {component} does not appear to be executable.")
|
||||||
|
|
||||||
|
|
||||||
def main(args: argparse.Namespace | dict | None = None):
|
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
@@ -439,21 +361,15 @@ def main(args: argparse.Namespace | dict | None = 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 path.startswith("archipelago://"):
|
||||||
args["args"] = (path, *args.get("args", ()))
|
handle_uri(path, args.get("args", ()))
|
||||||
# add the url arg to the passthrough args
|
return
|
||||||
components, text_client_component = handle_uri(path)
|
file, component = identify(path)
|
||||||
if not components:
|
if file:
|
||||||
args["component"] = text_client_component
|
args['file'] = file
|
||||||
else:
|
if component:
|
||||||
args['launch_components'] = [text_client_component, *components]
|
args['component'] = component
|
||||||
else:
|
if not component:
|
||||||
file, component = identify(path)
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
if file:
|
|
||||||
args['file'] = file
|
|
||||||
if component:
|
|
||||||
args['component'] = component
|
|
||||||
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()
|
||||||
@@ -462,7 +378,7 @@ def main(args: argparse.Namespace | dict | None = 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(args.get("launch_components", None), args.get("args", ()))
|
run_gui()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -484,7 +400,6 @@ 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,14 +26,12 @@ 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.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, Check
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
@@ -52,6 +50,22 @@ 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
|
||||||
@@ -86,23 +100,19 @@ class LAClientConstants:
|
|||||||
WRamCheckSize = 0x4
|
WRamCheckSize = 0x4
|
||||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||||
|
|
||||||
wRamStart = 0xC000
|
|
||||||
hRamStart = 0xFF80
|
|
||||||
hRamSize = 0x80
|
|
||||||
|
|
||||||
MinGameplayValue = 0x06
|
MinGameplayValue = 0x06
|
||||||
MaxGameplayValue = 0x1A
|
MaxGameplayValue = 0x1A
|
||||||
VictoryGameplayAndSub = 0x0102
|
VictoryGameplayAndSub = 0x0102
|
||||||
|
|
||||||
|
|
||||||
class RAGameboy():
|
class RAGameboy():
|
||||||
cache = []
|
cache = []
|
||||||
|
cache_start = 0
|
||||||
|
cache_size = 0
|
||||||
last_cache_read = None
|
last_cache_read = None
|
||||||
socket = None
|
socket = None
|
||||||
|
|
||||||
def __init__(self, address, port) -> None:
|
def __init__(self, address, port) -> None:
|
||||||
self.cache_start = LAClientConstants.wRamStart
|
|
||||||
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
|
|
||||||
|
|
||||||
self.address = address
|
self.address = address
|
||||||
self.port = port
|
self.port = port
|
||||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
@@ -121,14 +131,9 @@ class RAGameboy():
|
|||||||
async def get_retroarch_status(self):
|
async def get_retroarch_status(self):
|
||||||
return await self.send_command("GET_STATUS")
|
return await self.send_command("GET_STATUS")
|
||||||
|
|
||||||
def set_checks_range(self, checks_start, checks_size):
|
def set_cache_limits(self, cache_start, cache_size):
|
||||||
self.checks_start = checks_start
|
self.cache_start = cache_start
|
||||||
self.checks_size = checks_size
|
self.cache_size = cache_size
|
||||||
|
|
||||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
|
||||||
self.location_start = location_start
|
|
||||||
self.location_size = location_size
|
|
||||||
self.critical_location_addresses = critical_addresses
|
|
||||||
|
|
||||||
def send(self, b):
|
def send(self, b):
|
||||||
if type(b) is str:
|
if type(b) is str:
|
||||||
@@ -183,57 +188,21 @@ class RAGameboy():
|
|||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
attempts = 0
|
cache = []
|
||||||
while True:
|
remaining_size = self.cache_size
|
||||||
# RA doesn't let us do an atomic read of a large enough block of RAM
|
while remaining_size:
|
||||||
# Some bytes can't change in between reading location_block and hram_block
|
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||||
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
remaining_size -= len(block)
|
||||||
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
|
cache += block
|
||||||
verification_block = await self.read_memory_block(self.location_start, self.location_size)
|
|
||||||
|
|
||||||
valid = True
|
|
||||||
for address in self.critical_location_addresses:
|
|
||||||
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
|
|
||||||
valid = False
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
break
|
|
||||||
|
|
||||||
attempts += 1
|
|
||||||
|
|
||||||
# Shouldn't really happen, but keep it from choking
|
|
||||||
if attempts > 5:
|
|
||||||
return
|
|
||||||
|
|
||||||
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
|
|
||||||
|
|
||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.cache = bytearray(self.cache_size)
|
self.cache = cache
|
||||||
|
|
||||||
start = self.checks_start - self.cache_start
|
|
||||||
self.cache[start:start + len(checks_block)] = checks_block
|
|
||||||
|
|
||||||
start = self.location_start - self.cache_start
|
|
||||||
self.cache[start:start + len(location_block)] = location_block
|
|
||||||
|
|
||||||
start = LAClientConstants.hRamStart - self.cache_start
|
|
||||||
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):
|
|
||||||
block = bytearray()
|
|
||||||
remaining_size = size
|
|
||||||
while remaining_size:
|
|
||||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
|
||||||
remaining_size -= len(chunk)
|
|
||||||
block += chunk
|
|
||||||
|
|
||||||
return block
|
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
|
# TODO: can we just update once per frame?
|
||||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||||
await self.update_cache()
|
await self.update_cache()
|
||||||
if not self.cache:
|
if not self.cache:
|
||||||
@@ -390,12 +359,11 @@ class LinksAwakeningClient():
|
|||||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
|
|
||||||
async def wait_and_init_tracker(self, magpie: MagpieBridge):
|
async def wait_and_init_tracker(self):
|
||||||
await self.wait_for_game_ready()
|
await self.wait_for_game_ready()
|
||||||
self.tracker = LocationTracker(self.gameboy)
|
self.tracker = LocationTracker(self.gameboy)
|
||||||
self.item_tracker = ItemTracker(self.gameboy)
|
self.item_tracker = ItemTracker(self.gameboy)
|
||||||
self.gps_tracker = GpsTracker(self.gameboy)
|
self.gps_tracker = GpsTracker(self.gameboy)
|
||||||
magpie.gps_tracker = self.gps_tracker
|
|
||||||
|
|
||||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||||
# Don't allow getting an item until you've got your first check
|
# Don't allow getting an item until you've got your first check
|
||||||
@@ -437,11 +405,9 @@ class LinksAwakeningClient():
|
|||||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||||
|
|
||||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||||
await self.gameboy.update_cache()
|
|
||||||
await self.tracker.readChecks(item_get_cb)
|
await self.tracker.readChecks(item_get_cb)
|
||||||
await self.item_tracker.readItems()
|
await self.item_tracker.readItems()
|
||||||
await self.gps_tracker.read_location()
|
await self.gps_tracker.read_location()
|
||||||
await self.gps_tracker.read_entrances()
|
|
||||||
|
|
||||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||||
if self.deathlink_debounce and current_health != 0:
|
if self.deathlink_debounce and current_health != 0:
|
||||||
@@ -491,7 +457,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 = set()
|
found_checks = []
|
||||||
last_resend = time.time()
|
last_resend = time.time()
|
||||||
|
|
||||||
magpie_enabled = False
|
magpie_enabled = False
|
||||||
@@ -499,10 +465,6 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
@property
|
|
||||||
def slot_storage_key(self):
|
|
||||||
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:
|
||||||
self.client = LinksAwakeningClient()
|
self.client = LinksAwakeningClient()
|
||||||
self.slot_data = {}
|
self.slot_data = {}
|
||||||
@@ -514,9 +476,9 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from kvui import GameManager
|
import kvui
|
||||||
from kivy.metrics import dp
|
from kvui import Button, GameManager
|
||||||
from kivymd.uix.button import MDButton, MDButtonText
|
from kivy.uix.image import Image
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
class LADXManager(GameManager):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
@@ -529,27 +491,23 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
b = super().build()
|
b = super().build()
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
if self.ctx.magpie_enabled:
|
||||||
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
button = Button(text="", size=(30, 30), size_hint_x=None,
|
||||||
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
image = Image(size=(16, 16), texture=magpie_logo())
|
||||||
button.height = self.server_connect_bar.height
|
button.add_widget(image)
|
||||||
self.connect_layout.add_widget(button)
|
|
||||||
|
|
||||||
|
def set_center(_, center):
|
||||||
|
image.center = center
|
||||||
|
button.bind(center=set_center)
|
||||||
|
|
||||||
|
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_new_entrances(self, entrances: typing.Dict[str, str]):
|
async def send_checks(self):
|
||||||
# Store the entrances we find on the server for future sessions
|
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||||
message = [{
|
|
||||||
"cmd": "Set",
|
|
||||||
"key": self.slot_storage_key,
|
|
||||||
"default": {},
|
|
||||||
"want_reply": False,
|
|
||||||
"operations": [{"operation": "update", "value": entrances}],
|
|
||||||
}]
|
|
||||||
|
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
|
|
||||||
had_invalid_slot_data = None
|
had_invalid_slot_data = None
|
||||||
@@ -579,19 +537,13 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
async def request_found_entrances(self):
|
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
|
||||||
|
|
||||||
# Ask for updates so that players can co-op entrances in a seed
|
|
||||||
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.update(item_ids)
|
self.found_checks += item_ids
|
||||||
create_task_log_exception(self.check_locations(self.found_checks))
|
create_task_log_exception(self.send_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))
|
||||||
|
|
||||||
@@ -619,40 +571,16 @@ 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"]:
|
|
||||||
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
|
||||||
|
|
||||||
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
|
|
||||||
self.client.gps_tracker.receive_found_entrances(args["value"])
|
|
||||||
|
|
||||||
async def sync(self):
|
async def sync(self):
|
||||||
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):
|
||||||
@@ -661,8 +589,6 @@ 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])
|
||||||
|
|
||||||
self.add_linked_items(ladxr_checks)
|
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
|
|
||||||
@@ -696,38 +622,21 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if not self.client.recvd_checks:
|
if not self.client.recvd_checks:
|
||||||
await self.sync()
|
await self.sync()
|
||||||
|
|
||||||
await self.client.wait_and_init_tracker(self.magpie)
|
await self.client.wait_and_init_tracker()
|
||||||
|
|
||||||
min_tick_duration = 0.1
|
|
||||||
last_tick = time.time()
|
|
||||||
while True:
|
while True:
|
||||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
tick_duration = now - last_tick
|
|
||||||
sleep_duration = max(min_tick_duration - tick_duration, 0)
|
|
||||||
await asyncio.sleep(sleep_duration)
|
|
||||||
|
|
||||||
last_tick = now
|
|
||||||
|
|
||||||
if self.last_resend + 5.0 < now:
|
if self.last_resend + 5.0 < now:
|
||||||
self.last_resend = now
|
self.last_resend = now
|
||||||
await self.check_locations(self.found_checks)
|
await self.send_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)
|
||||||
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
await self.magpie.send_gps(self.client.gps_tracker)
|
||||||
self.magpie.slot_data = self.slot_data
|
self.magpie.slot_data = self.slot_data
|
||||||
await self.magpie.send_slot_data()
|
|
||||||
|
|
||||||
if self.client.gps_tracker.needs_found_entrances:
|
|
||||||
await self.request_found_entrances()
|
|
||||||
self.client.gps_tracker.needs_found_entrances = False
|
|
||||||
|
|
||||||
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
|
|
||||||
if new_entrances:
|
|
||||||
await self.send_new_entrances(new_entrances)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let magpie errors take out the client
|
# Don't let magpie errors take out the client
|
||||||
pass
|
pass
|
||||||
@@ -738,8 +647,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 = LinksAwakeningWorld.settings.rom_start
|
auto_start = typing.cast(typing.Union[bool, str],
|
||||||
|
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)
|
||||||
@@ -796,6 +705,6 @@ async def main():
|
|||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
67
Main.py
67
Main.py
@@ -7,13 +7,14 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
||||||
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
flood_items
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple
|
from Utils import __version__, output_path, version_tuple, get_settings
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -21,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
|||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_settings().server_options.as_dict()
|
||||||
assert isinstance(baked_server_options, dict)
|
assert isinstance(baked_server_options, dict)
|
||||||
@@ -36,6 +37,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
multiworld.plando_options = args.plando_options
|
||||||
|
multiworld.plando_items = args.plando_items.copy()
|
||||||
|
multiworld.plando_texts = args.plando_texts.copy()
|
||||||
|
multiworld.plando_connections = args.plando_connections.copy()
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -52,18 +56,32 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
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}}: Items: {len(cls.item_names):{item_count}} | "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||||
f"Locations: {len(cls.location_names):{location_count}}")
|
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||||
|
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_count, location_count
|
del item_digits, location_digits, 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 and not args.spoiler_only:
|
if not args.skip_output:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -131,15 +149,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
fallback_inventory = StartInventoryPool({})
|
fallback_inventory = StartInventoryPool({})
|
||||||
depletion_pool: dict[int, dict[str, int]] = {
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||||
for player in multiworld.player_ids
|
for player in multiworld.player_ids
|
||||||
}
|
}
|
||||||
@@ -148,7 +164,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if target_per_player:
|
if target_per_player:
|
||||||
new_itempool: list[Item] = []
|
new_itempool: List[Item] = []
|
||||||
|
|
||||||
# Make new itempool with start_inventory_from_pool items removed
|
# Make new itempool with start_inventory_from_pool items removed
|
||||||
for item in multiworld.itempool:
|
for item in multiworld.itempool:
|
||||||
@@ -177,9 +193,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
resolve_early_locations_for_planned(multiworld)
|
|
||||||
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
distribute_planned(multiworld)
|
||||||
for x in multiworld.plando_item_blocks[player]])
|
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
@@ -209,15 +224,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
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__
|
||||||
@@ -232,7 +238,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: dict[int, dict[int, str]] = {}
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
@@ -273,7 +279,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in multiworld.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in multiworld.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
@@ -300,14 +306,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
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, int | list[int]]] = {}
|
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||||
|
|
||||||
# get spheres -> filter address==None -> skip empty
|
# get spheres -> filter address==None -> skip empty
|
||||||
spheres: list[dict[int, set[int]]] = []
|
spheres: List[Dict[int, Set[int]]] = []
|
||||||
for sphere in multiworld.get_sendable_spheres():
|
for sphere in multiworld.get_sendable_spheres():
|
||||||
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
||||||
for sphere_location in sphere:
|
for sphere_location in sphere:
|
||||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import requests
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from Utils import is_windows
|
from Utils import is_windows
|
||||||
from settings import get_settings
|
|
||||||
|
|
||||||
atexit.register(input, "Press enter to exit.")
|
atexit.register(input, "Press enter to exit.")
|
||||||
|
|
||||||
@@ -148,11 +147,9 @@ def find_jdk(version: str) -> str:
|
|||||||
if os.path.isfile(jdk_exe):
|
if os.path.isfile(jdk_exe):
|
||||||
return jdk_exe
|
return jdk_exe
|
||||||
else:
|
else:
|
||||||
jdk_exe = shutil.which(options.java)
|
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||||
if not jdk_exe:
|
if not jdk_exe:
|
||||||
jdk_exe = shutil.which("java") # try to fall back to system java
|
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||||
if not jdk_exe:
|
|
||||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
|
||||||
return jdk_exe
|
return jdk_exe
|
||||||
|
|
||||||
|
|
||||||
@@ -288,8 +285,8 @@ if __name__ == '__main__':
|
|||||||
# Change to executable's working directory
|
# Change to executable's working directory
|
||||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||||
|
|
||||||
options = get_settings().minecraft_options
|
options = Utils.get_options()
|
||||||
channel = args.channel or options.release_channel
|
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||||
apmc_data = None
|
apmc_data = None
|
||||||
data_version = args.data_version or None
|
data_version = args.data_version or None
|
||||||
|
|
||||||
@@ -302,8 +299,8 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
versions = get_minecraft_versions(data_version, channel)
|
versions = get_minecraft_versions(data_version, channel)
|
||||||
|
|
||||||
forge_dir = options.forge_directory
|
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||||
max_heap = options.max_heap_size
|
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||||
forge_version = args.forge or versions["forge"]
|
forge_version = args.forge or versions["forge"]
|
||||||
java_version = args.java or versions["java"]
|
java_version = args.java or versions["java"]
|
||||||
mod_url = versions["url"]
|
mod_url = versions["url"]
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ 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)
|
||||||
min_client_version = Version(0, 5, 0)
|
colorama.init()
|
||||||
colorama.just_fix_windows_console()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
@@ -67,13 +66,9 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_container_unique(container, entries):
|
def update_dict(dictionary, entries):
|
||||||
if isinstance(container, list):
|
dictionary.update(entries)
|
||||||
existing_container_as_set = set(container)
|
return dictionary
|
||||||
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():
|
||||||
@@ -114,7 +109,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_container_unique,
|
"update": update_dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -458,12 +453,8 @@ class Context:
|
|||||||
self.generator_version = Version(*decoded_obj["version"])
|
self.generator_version = Version(*decoded_obj["version"])
|
||||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||||
self.minimum_client_versions = {}
|
self.minimum_client_versions = {}
|
||||||
if self.generator_version < Version(0, 6, 2):
|
|
||||||
min_version = Version(0, 1, 6)
|
|
||||||
else:
|
|
||||||
min_version = min_client_version
|
|
||||||
for player, version in clients_ver.items():
|
for player, version in clients_ver.items():
|
||||||
self.minimum_client_versions[player] = max(Version(*version), min_version)
|
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
@@ -1830,7 +1821,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
@@ -1904,7 +1895,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
old_tags = client.tags
|
old_tags = client.tags
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
client.no_text = "NoText" in client.tags or (
|
client.no_text = "NoText" in client.tags or (
|
||||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
)
|
)
|
||||||
@@ -1987,21 +1978,14 @@ 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)
|
||||||
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
||||||
for slot in concerning_slots:
|
|
||||||
ctx.replace_hint(client.team, slot, hint, new_hint)
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
for slot in concerning_slots:
|
ctx.on_changed_hints(client.team, hint.finding_player)
|
||||||
ctx.on_changed_hints(client.team, slot)
|
ctx.on_changed_hints(client.team, hint.receiving_player)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
update_client_status(ctx, client, args["status"])
|
||||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
|
||||||
"text": "Trackers can't register Goal Complete",
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
else:
|
|
||||||
update_client_status(ctx, client, args["status"])
|
|
||||||
|
|
||||||
elif cmd == 'Say':
|
elif cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
@@ -2053,7 +2037,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", False):
|
if args.get("want_reply", True):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2428,10 +2412,8 @@ 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 = get_settings().server_options.as_dict()
|
defaults = Utils.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)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot import OOTWorld
|
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
@@ -281,7 +280,7 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = OOTWorld.settings.rom_start
|
auto_start = Utils.get_options()["oot_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)
|
||||||
@@ -296,7 +295,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = OOTWorld.settings.rom_file
|
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||||
rom = Rom(rom_file_name)
|
rom = Rom(rom_file_name)
|
||||||
|
|
||||||
sub_file = None
|
sub_file = None
|
||||||
@@ -347,7 +346,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
267
Options.py
267
Options.py
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -24,12 +23,6 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: int | float) -> bool:
|
|
||||||
"""Roll a percentage chance.
|
|
||||||
percentage is expected to be in range [0, 100]"""
|
|
||||||
return random.random() < (float(percentage) / 100)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
class OptionError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -873,49 +866,15 @@ 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
|
||||||
|
|
||||||
min = 0
|
def __init__(self, value: typing.Dict[str, int]):
|
||||||
|
if any(item_count is None for item_count in value.values()):
|
||||||
def __init__(self, value: dict[str, int]) -> None:
|
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
||||||
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
if any(item_count < 1 for item_count in value.values()):
|
||||||
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
raise Exception("Cannot have non-positive item counts.")
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -1025,7 +984,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if isinstance(data, typing.Iterable):
|
if isinstance(data, typing.Iterable):
|
||||||
for text in data:
|
for text in data:
|
||||||
if isinstance(text, typing.Mapping):
|
if isinstance(text, typing.Mapping):
|
||||||
if roll_percentage(text.get("percentage", 100)):
|
if random.random() < float(text.get("percentage", 100)/100):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
if isinstance(at, dict):
|
if isinstance(at, dict):
|
||||||
@@ -1051,7 +1010,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
else:
|
else:
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if roll_percentage(text.percentage):
|
if random.random() < float(text.percentage/100):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||||
@@ -1175,7 +1134,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
for connection in data:
|
for connection in data:
|
||||||
if isinstance(connection, typing.Mapping):
|
if isinstance(connection, typing.Mapping):
|
||||||
percentage = connection.get("percentage", 100)
|
percentage = connection.get("percentage", 100)
|
||||||
if roll_percentage(percentage):
|
if random.random() < float(percentage / 100):
|
||||||
entrance = connection.get("entrance", None)
|
entrance = connection.get("entrance", None)
|
||||||
if is_iterable_except_str(entrance):
|
if is_iterable_except_str(entrance):
|
||||||
entrance = random.choice(sorted(entrance))
|
entrance = random.choice(sorted(entrance))
|
||||||
@@ -1193,7 +1152,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
percentage
|
percentage
|
||||||
))
|
))
|
||||||
elif isinstance(connection, PlandoConnection):
|
elif isinstance(connection, PlandoConnection):
|
||||||
if roll_percentage(connection.percentage):
|
if random.random() < float(connection.percentage / 100):
|
||||||
value.append(connection)
|
value.append(connection)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||||
@@ -1298,47 +1257,42 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(
|
def as_dict(self,
|
||||||
self,
|
*option_names: str,
|
||||||
*option_names: str,
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
|
||||||
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 get the values of.
|
:param option_names: names of the options to return
|
||||||
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
||||||
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
|
||||||
|
|
||||||
: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 not in type(self).type_hints:
|
if option_name in type(self).type_hints:
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
if casing == "snake":
|
elif casing == "camel":
|
||||||
display_name = option_name
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
elif casing == "camel":
|
split_name[0] = split_name[0].lower()
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
display_name = "".join(split_name)
|
||||||
split_name[0] = split_name[0].lower()
|
elif casing == "pascal":
|
||||||
display_name = "".join(split_name)
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
elif casing == "pascal":
|
elif casing == "kebab":
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
display_name = option_name.replace("_", "-")
|
||||||
elif casing == "kebab":
|
else:
|
||||||
display_name = option_name.replace("_", "-")
|
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:
|
else:
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
"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
|
||||||
|
|
||||||
|
|
||||||
@@ -1359,7 +1313,6 @@ 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):
|
||||||
@@ -1475,133 +1428,6 @@ class ItemLinks(OptionList):
|
|||||||
link["item_pool"] = list(pool)
|
link["item_pool"] = list(pool)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlandoItem:
|
|
||||||
items: list[str] | dict[str, typing.Any]
|
|
||||||
locations: list[str]
|
|
||||||
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
|
||||||
from_pool: bool = True
|
|
||||||
force: bool | typing.Literal["silent"] = "silent"
|
|
||||||
count: int | bool | dict[str, int] = False
|
|
||||||
percentage: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
class PlandoItems(Option[typing.List[PlandoItem]]):
|
|
||||||
"""Generic items plando."""
|
|
||||||
default = ()
|
|
||||||
supports_weighting = False
|
|
||||||
display_name = "Plando Items"
|
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
|
||||||
self.value = list(deepcopy(value))
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
|
||||||
if not isinstance(data, typing.Iterable):
|
|
||||||
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
|
||||||
|
|
||||||
value: typing.List[PlandoItem] = []
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, typing.Mapping):
|
|
||||||
percentage = item.get("percentage", 100)
|
|
||||||
if not isinstance(percentage, int):
|
|
||||||
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
|
||||||
if not (0 <= percentage <= 100):
|
|
||||||
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
|
||||||
if roll_percentage(percentage):
|
|
||||||
count = item.get("count", False)
|
|
||||||
items = item.get("items", [])
|
|
||||||
if not items:
|
|
||||||
items = item.get("item", None) # explicitly throw an error here if not present
|
|
||||||
if not items:
|
|
||||||
raise OptionError("You must specify at least one item to place items with plando.")
|
|
||||||
count = 1
|
|
||||||
if isinstance(items, str):
|
|
||||||
items = [items]
|
|
||||||
elif not isinstance(items, (dict, list)):
|
|
||||||
raise OptionError(f"Plando 'items' has to be string, list, or "
|
|
||||||
f"dictionary, not {type(items)}")
|
|
||||||
locations = item.get("locations", [])
|
|
||||||
if not locations:
|
|
||||||
locations = item.get("location", [])
|
|
||||||
if locations:
|
|
||||||
count = 1
|
|
||||||
else:
|
|
||||||
locations = ["Everywhere"]
|
|
||||||
if isinstance(locations, str):
|
|
||||||
locations = [locations]
|
|
||||||
if not isinstance(locations, list):
|
|
||||||
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
|
||||||
world = item.get("world", False)
|
|
||||||
from_pool = item.get("from_pool", True)
|
|
||||||
force = item.get("force", "silent")
|
|
||||||
if not isinstance(from_pool, bool):
|
|
||||||
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
|
||||||
if not (isinstance(force, bool) or force == "silent"):
|
|
||||||
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
|
||||||
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
|
||||||
elif isinstance(item, PlandoItem):
|
|
||||||
if roll_percentage(item.percentage):
|
|
||||||
value.append(item)
|
|
||||||
else:
|
|
||||||
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
|
||||||
return cls(value)
|
|
||||||
|
|
||||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
|
||||||
if not self.value:
|
|
||||||
return
|
|
||||||
from BaseClasses import PlandoOptions
|
|
||||||
if not (PlandoOptions.items & plando_options):
|
|
||||||
# plando is disabled but plando options were given so overwrite the options
|
|
||||||
self.value = []
|
|
||||||
logging.warning(f"The plando items module is turned off, "
|
|
||||||
f"so items for {player_name} will be ignored.")
|
|
||||||
else:
|
|
||||||
# filter down item groups
|
|
||||||
for plando in self.value:
|
|
||||||
# confirm a valid count
|
|
||||||
if isinstance(plando.count, dict):
|
|
||||||
if "min" in plando.count and "max" in plando.count:
|
|
||||||
if plando.count["min"] > plando.count["max"]:
|
|
||||||
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
|
||||||
items_copy = plando.items.copy()
|
|
||||||
if isinstance(plando.items, dict):
|
|
||||||
for item in items_copy:
|
|
||||||
if item in world.item_name_groups:
|
|
||||||
value = plando.items.pop(item)
|
|
||||||
group = world.item_name_groups[item]
|
|
||||||
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
|
||||||
if not filtered_items:
|
|
||||||
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
|
||||||
f"and every item in it. This is not allowed.")
|
|
||||||
if value is True:
|
|
||||||
for key in filtered_items:
|
|
||||||
plando.items[key] = True
|
|
||||||
else:
|
|
||||||
for key in random.choices(filtered_items, k=value):
|
|
||||||
plando.items[key] = plando.items.get(key, 0) + 1
|
|
||||||
else:
|
|
||||||
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
|
||||||
for item in items_copy:
|
|
||||||
if item in world.item_name_groups:
|
|
||||||
plando.items.remove(item)
|
|
||||||
plando.items.extend(sorted(world.item_name_groups[item]))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
|
||||||
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
|
||||||
return self.value.__getitem__(index)
|
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
|
||||||
yield from self.value
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1624,7 +1450,6 @@ class PerGameCommonOptions(CommonOptions):
|
|||||||
exclude_locations: ExcludeLocations
|
exclude_locations: ExcludeLocations
|
||||||
priority_locations: PriorityLocations
|
priority_locations: PriorityLocations
|
||||||
item_links: ItemLinks
|
item_links: ItemLinks
|
||||||
plando_items: PlandoItems
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1678,7 +1503,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
|
|||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
from inspect import cleandoc
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -1717,21 +1541,19 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
# yaml dump may add end of document marker and newlines.
|
# yaml dump may add end of document marker and newlines.
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||||
|
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
|
||||||
template = Template(file_data)
|
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
option_groups = get_option_groups(world)
|
||||||
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
res = template.render(
|
file_data = f.read()
|
||||||
|
res = Template(file_data).render(
|
||||||
option_groups=option_groups,
|
option_groups=option_groups,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
cleandoc=cleandoc,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
del file_data
|
||||||
|
|
||||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
@@ -1757,7 +1579,6 @@ 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():
|
||||||
@@ -1770,7 +1591,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 = ["ID", "Game", "Name", *all_option_names]
|
fields = ["Game", "Name", *all_option_names]
|
||||||
writer = DictWriter(file, fields)
|
writer = DictWriter(file, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(output)
|
writer.writerows(output)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -62,6 +63,7 @@ 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
|
||||||
@@ -78,11 +80,6 @@ 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
|
|
||||||
* Jak and Daxter: The Precursor Legacy
|
|
||||||
* Super Mario Land 2: 6 Golden Coins
|
|
||||||
* shapez
|
|
||||||
|
|
||||||
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.just_fix_windows_console()
|
colorama.init()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ def main():
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
23
Utils.py
23
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.2"
|
__version__ = "0.6.0"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -114,8 +114,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -139,11 +137,8 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if globals().get("__file__") and os.path.isfile(__file__):
|
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
|
||||||
# we are running in a normal Python environment, but AP was imported weirdly
|
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
# pray
|
# pray
|
||||||
@@ -432,9 +427,6 @@ 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"}:
|
||||||
@@ -451,8 +443,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
else:
|
else:
|
||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
||||||
self.options_module.PlandoText)):
|
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -540,8 +531,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
|||||||
if add_timestamp:
|
if add_timestamp:
|
||||||
stream_handler.setFormatter(formatter)
|
stream_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(stream_handler)
|
root_logger.addHandler(stream_handler)
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||||
@@ -640,8 +629,6 @@ 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)))
|
||||||
|
|
||||||
@@ -662,10 +649,8 @@ 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] == 101:
|
if picks[0][1] == 100:
|
||||||
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)"
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import typing
|
import shutil
|
||||||
from typing import Tuple, List, Iterable, Dict
|
from typing import Tuple, List, Iterable, Dict
|
||||||
|
|
||||||
from . import WargrooveWorld
|
from worlds.wargroove import WargrooveWorld
|
||||||
from .Items import item_table, faction_table, CommanderData, ItemData
|
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -22,7 +21,7 @@ import logging
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||||
|
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import NetworkItem, ClientStatus
|
||||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||||
CommonContext, server_loop
|
CommonContext, server_loop
|
||||||
|
|
||||||
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
|
|||||||
|
|
||||||
|
|
||||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||||
def _cmd_sacrifice_summon(self):
|
|
||||||
"""Toggles sacrifices and summons On/Off"""
|
|
||||||
if isinstance(self.ctx, WargrooveContext):
|
|
||||||
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
|
|
||||||
if self.ctx.has_sacrifice_summon:
|
|
||||||
self.output(f"Sacrifices and summons are enabled.")
|
|
||||||
else:
|
|
||||||
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
|
|
||||||
if os.path.exists(unit_summon_response_file):
|
|
||||||
os.remove(unit_summon_response_file)
|
|
||||||
self.output(f"Sacrifices and summons are disabled.")
|
|
||||||
|
|
||||||
def _cmd_deathlink(self):
|
|
||||||
"""Toggles deathlink On/Off"""
|
|
||||||
if isinstance(self.ctx, WargrooveContext):
|
|
||||||
self.ctx.has_death_link = not self.ctx.has_death_link
|
|
||||||
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
|
|
||||||
if self.ctx.has_death_link:
|
|
||||||
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
|
|
||||||
if os.path.exists(death_link_send_file):
|
|
||||||
os.remove(death_link_send_file)
|
|
||||||
self.output(f"Deathlink enabled.")
|
|
||||||
else:
|
|
||||||
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
|
|
||||||
if os.path.exists(death_link_receive_file):
|
|
||||||
os.remove(death_link_receive_file)
|
|
||||||
self.output(f"Deathlink disabled.")
|
|
||||||
|
|
||||||
def _cmd_resync(self):
|
def _cmd_resync(self):
|
||||||
"""Manually trigger a resync."""
|
"""Manually trigger a resync."""
|
||||||
self.output(f"Syncing items.")
|
self.output(f"Syncing items.")
|
||||||
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
|
|||||||
commander_defense_boost_multiplier: int = 0
|
commander_defense_boost_multiplier: int = 0
|
||||||
income_boost_multiplier: int = 0
|
income_boost_multiplier: int = 0
|
||||||
starting_groove_multiplier: float
|
starting_groove_multiplier: float
|
||||||
has_death_link: bool = False
|
|
||||||
has_sacrifice_summon: bool = True
|
|
||||||
player_stored_units_key: str = ""
|
|
||||||
ai_stored_units_key: str = ""
|
|
||||||
max_stored_units: int = 1000
|
|
||||||
faction_item_ids = {
|
faction_item_ids = {
|
||||||
'Starter': 0,
|
'Starter': 0,
|
||||||
'Cherrystone': 52025,
|
'Cherrystone': 52025,
|
||||||
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
|
|||||||
'Income Boost': 52023,
|
'Income Boost': 52023,
|
||||||
'Commander Defense Boost': 52024,
|
'Commander Defense Boost': 52024,
|
||||||
}
|
}
|
||||||
unit_classes = {
|
|
||||||
"archer",
|
|
||||||
"ballista",
|
|
||||||
"balloon",
|
|
||||||
"dog",
|
|
||||||
"dragon",
|
|
||||||
"giant",
|
|
||||||
"harpoonship",
|
|
||||||
"harpy",
|
|
||||||
"knight",
|
|
||||||
"mage",
|
|
||||||
"merman",
|
|
||||||
"rifleman",
|
|
||||||
"soldier",
|
|
||||||
"spearman",
|
|
||||||
"thief",
|
|
||||||
"thief_with_gold",
|
|
||||||
"travelboat",
|
|
||||||
"trebuchet",
|
|
||||||
"turtle",
|
|
||||||
"villager",
|
|
||||||
"wagon",
|
|
||||||
"warship",
|
|
||||||
"witch",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
def __init__(self, server_address, password):
|
||||||
super(WargrooveContext, self).__init__(server_address, password)
|
super(WargrooveContext, self).__init__(server_address, password)
|
||||||
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
|
|||||||
self.syncing = False
|
self.syncing = False
|
||||||
self.awaiting_bridge = False
|
self.awaiting_bridge = False
|
||||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||||
game_options = WargrooveWorld.settings
|
|
||||||
|
|
||||||
# Validate the AppData directory with Wargroove save data.
|
|
||||||
# By default, Windows sets an environment variable we can leverage.
|
|
||||||
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
|
|
||||||
appdata_wargroove = None
|
|
||||||
if "appdata" in os.environ:
|
if "appdata" in os.environ:
|
||||||
appdata_wargroove = os.environ['appdata']
|
options = Utils.get_options()
|
||||||
else:
|
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
||||||
try:
|
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
||||||
appdata_wargroove = game_options.save_directory
|
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
||||||
except FileNotFoundError:
|
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
||||||
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n"
|
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||||
"Unable to infer required game_communication_path.\n"
|
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
||||||
"Try setting the \"save_directory\" value in your local options file "
|
"Unable to infer required game_communication_path")
|
||||||
"to the AppData folder containing your Wargroove saves.")
|
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||||
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove"))
|
if not os.path.exists(self.game_communication_path):
|
||||||
if not os.path.isdir(appdata_wargroove):
|
os.makedirs(self.game_communication_path)
|
||||||
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n"
|
self.remove_communication_files()
|
||||||
f"Looked in \"{appdata_wargroove}\".\n"
|
atexit.register(self.remove_communication_files)
|
||||||
f"If you haven't yet booted the game at least once, boot Wargroove "
|
if not os.path.isdir(appdata_wargroove):
|
||||||
f"and then close it to attempt to fix this error.\n"
|
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
||||||
f"If the AppData folder above seems wrong, try setting the "
|
"Boot Wargroove and then close it to attempt to fix this error")
|
||||||
f"\"save_directory\" value in your local options file "
|
if not os.path.isdir(data_directory):
|
||||||
f"to the AppData folder containing your Wargroove saves.")
|
data_directory = dev_data_directory
|
||||||
|
if not os.path.isdir(data_directory):
|
||||||
# Check for the Wargroove game executable path.
|
|
||||||
# This should always be set regardless of the OS.
|
|
||||||
root_directory = game_options["root_directory"]
|
|
||||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
|
||||||
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
|
|
||||||
f"\"{root_directory}/win64_bin/\".\n"
|
|
||||||
f"Unable to infer required game_communication_path.\n"
|
|
||||||
f"Please verify the \"root_directory\" value in your local "
|
|
||||||
f"options file is set correctly.")
|
|
||||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
|
||||||
if not os.path.exists(self.game_communication_path):
|
|
||||||
os.makedirs(self.game_communication_path)
|
|
||||||
self.remove_communication_files()
|
|
||||||
atexit.register(self.remove_communication_files)
|
|
||||||
if not os.path.isdir(appdata_wargroove):
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
|
||||||
"Boot Wargroove and then close it to attempt to fix this error")
|
|
||||||
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
|
|
||||||
save_directory = os.path.join(appdata_wargroove, "save")
|
|
||||||
|
|
||||||
# Wargroove doesn't always create the mods directory, so we have to do it
|
|
||||||
if not os.path.isdir(mods_directory):
|
|
||||||
os.makedirs(mods_directory)
|
|
||||||
resources = ["data/mods/ArchipelagoMod/maps.dat",
|
|
||||||
"data/mods/ArchipelagoMod/mod.dat",
|
|
||||||
"data/mods/ArchipelagoMod/modAssets.dat",
|
|
||||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
|
|
||||||
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
|
|
||||||
file_paths = [os.path.join(mods_directory, "maps.dat"),
|
|
||||||
os.path.join(mods_directory, "mod.dat"),
|
|
||||||
os.path.join(mods_directory, "modAssets.dat"),
|
|
||||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
|
|
||||||
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
|
|
||||||
for resource, destination in zip(resources, file_paths):
|
|
||||||
file_data = pkgutil.get_data("worlds.wargroove", resource)
|
|
||||||
if file_data is None:
|
|
||||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
||||||
with open(destination, 'wb') as f:
|
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
||||||
f.write(file_data)
|
else:
|
||||||
|
print_error_and_close("WargrooveClient couldn't detect system type. "
|
||||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
"Unable to infer required game_communication_path")
|
||||||
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
|
|
||||||
text = data.get("cause", "")
|
|
||||||
if text:
|
|
||||||
f.write(f"DeathLink: {text}")
|
|
||||||
else:
|
|
||||||
f.write(f"DeathLink: Received from {data['source']}")
|
|
||||||
super(WargrooveContext, self).on_deathlink(data)
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
|
|||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd in {"Connected"}:
|
if cmd in {"Connected"}:
|
||||||
slot_data = args["slot_data"]
|
|
||||||
self.has_death_link = slot_data.get("death_link", False)
|
|
||||||
filename = f"AP_settings.json"
|
filename = f"AP_settings.json"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
json.dump(slot_data, f)
|
slot_data = args["slot_data"]
|
||||||
|
json.dump(args["slot_data"], f)
|
||||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||||
print('can choose commander:', self.can_choose_commander)
|
print('can choose commander:', self.can_choose_commander)
|
||||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
||||||
self.income_boost_multiplier = slot_data["income_boost"]
|
self.income_boost_multiplier = slot_data["income_boost"]
|
||||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
||||||
|
f.close()
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
pass
|
f.close()
|
||||||
|
|
||||||
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
|
|
||||||
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
|
|
||||||
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
|
|
||||||
|
|
||||||
self.update_commander_data()
|
self.update_commander_data()
|
||||||
self.ui.update_tracker()
|
self.ui.update_tracker()
|
||||||
|
|
||||||
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
|
|||||||
filename = f"seed{i}"
|
filename = f"seed{i}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
f.write(str(random.randint(0, 4294967295)))
|
f.write(str(random.randint(0, 4294967295)))
|
||||||
|
f.close()
|
||||||
|
|
||||||
if cmd in {"RoomInfo"}:
|
if cmd in {"RoomInfo"}:
|
||||||
self.seed_name = args["seed_name"]
|
self.seed_name = args["seed_name"]
|
||||||
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
|
|||||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
||||||
else:
|
else:
|
||||||
f.write(f"{item_count}")
|
f.write(f"{item_count}")
|
||||||
|
f.close()
|
||||||
|
|
||||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
print_filename = f"AP_{str(network_item.item)}.item.print"
|
||||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
print_path = os.path.join(self.game_communication_path, print_filename)
|
||||||
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
|
|||||||
self.item_names.lookup_in_game(network_item.item) +
|
self.item_names.lookup_in_game(network_item.item) +
|
||||||
" from " +
|
" from " +
|
||||||
self.player_names[network_item.player])
|
self.player_names[network_item.player])
|
||||||
|
f.close()
|
||||||
self.update_commander_data()
|
self.update_commander_data()
|
||||||
self.ui.update_tracker()
|
self.ui.update_tracker()
|
||||||
|
|
||||||
@@ -319,16 +209,22 @@ class WargrooveContext(CommonContext):
|
|||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||||
pass
|
f.close()
|
||||||
|
|
||||||
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 kivymd.uix.tab import MDTabsItem, MDTabsItemText
|
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||||
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):
|
||||||
@@ -495,6 +391,7 @@ class WargrooveContext(CommonContext):
|
|||||||
|
|
||||||
|
|
||||||
async def game_watcher(ctx: WargrooveContext):
|
async def game_watcher(ctx: WargrooveContext):
|
||||||
|
from worlds.wargroove.Locations import location_table
|
||||||
while not ctx.exit_event.is_set():
|
while not ctx.exit_event.is_set():
|
||||||
if ctx.syncing == True:
|
if ctx.syncing == True:
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
@@ -506,12 +403,6 @@ async def game_watcher(ctx: WargrooveContext):
|
|||||||
victory = False
|
victory = False
|
||||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||||
for file in files:
|
for file in files:
|
||||||
if file == "deathLinkSend" and ctx.has_death_link:
|
|
||||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
|
||||||
failed_mission = f.read()
|
|
||||||
if ctx.slot is not None:
|
|
||||||
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}")
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
if file.find("send") > -1:
|
if file.find("send") > -1:
|
||||||
st = file.split("send", -1)[1]
|
st = file.split("send", -1)[1]
|
||||||
sending = sending+[(int(st))]
|
sending = sending+[(int(st))]
|
||||||
@@ -519,40 +410,6 @@ async def game_watcher(ctx: WargrooveContext):
|
|||||||
if file.find("victory") > -1:
|
if file.find("victory") > -1:
|
||||||
victory = True
|
victory = True
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
os.remove(os.path.join(ctx.game_communication_path, file))
|
||||||
if file == "unitSacrifice" or file == "unitSacrificeAI":
|
|
||||||
if ctx.has_sacrifice_summon:
|
|
||||||
stored_units_key = ctx.player_stored_units_key
|
|
||||||
if file == "unitSacrificeAI":
|
|
||||||
stored_units_key = ctx.ai_stored_units_key
|
|
||||||
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
|
|
||||||
unit_class = f.read()
|
|
||||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
|
||||||
"default": [],
|
|
||||||
"want_reply": True,
|
|
||||||
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
|
|
||||||
if ctx.has_sacrifice_summon:
|
|
||||||
stored_units_key = ctx.player_stored_units_key
|
|
||||||
if file == "unitSummonRequestAI":
|
|
||||||
stored_units_key = ctx.ai_stored_units_key
|
|
||||||
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
|
|
||||||
if stored_units_key in ctx.stored_data:
|
|
||||||
stored_units = ctx.stored_data[stored_units_key]
|
|
||||||
if stored_units is None:
|
|
||||||
stored_units = []
|
|
||||||
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
|
|
||||||
if len(wg1_stored_units) != 0:
|
|
||||||
summoned_unit = random.choice(wg1_stored_units)
|
|
||||||
message = [{"cmd": 'Set', "key": stored_units_key,
|
|
||||||
"default": [],
|
|
||||||
"want_reply": True,
|
|
||||||
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
|
|
||||||
await ctx.send_msgs(message)
|
|
||||||
f.write(summoned_unit)
|
|
||||||
os.remove(os.path.join(ctx.game_communication_path, file))
|
|
||||||
|
|
||||||
ctx.locations_checked = sending
|
ctx.locations_checked = sending
|
||||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||||
await ctx.send_msgs(message)
|
await ctx.send_msgs(message)
|
||||||
@@ -567,9 +424,8 @@ def print_error_and_close(msg):
|
|||||||
Utils.messagebox("Error", msg, error=True)
|
Utils.messagebox("Error", msg, error=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def launch(*launch_args: str):
|
if __name__ == '__main__':
|
||||||
async def main():
|
async def main(args):
|
||||||
args = parser.parse_args(launch_args)
|
|
||||||
ctx = WargrooveContext(args.connect, args.password)
|
ctx = WargrooveContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -589,6 +445,7 @@ def launch(*launch_args: str):
|
|||||||
|
|
||||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||||
|
|
||||||
colorama.just_fix_windows_console()
|
args, rest = parser.parse_known_args()
|
||||||
asyncio.run(main())
|
colorama.init()
|
||||||
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
@@ -80,8 +80,10 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
|
import worlds.AutoWorld
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||||
|
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
|
|||||||
@@ -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),
|
"players": get_players(seed.slots),
|
||||||
})
|
})
|
||||||
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, PrimaryKey
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
@@ -36,21 +36,12 @@ 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(_mp_gen_game, (options,),
|
pool.apply_async(gen_game, (options,),
|
||||||
{"meta": meta,
|
{"meta": meta,
|
||||||
"sid": generation.id,
|
"sid": generation.id,
|
||||||
"owner": generation.owner},
|
"owner": generation.owner},
|
||||||
@@ -64,10 +55,6 @@ 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,9 +227,6 @@ 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
|
||||||
@@ -250,23 +247,8 @@ 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
|
||||||
if not cert_file:
|
del cert_file, cert_key_file, ponyconfig
|
||||||
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()
|
||||||
@@ -281,12 +263,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=get_ssl_context())
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=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=get_ssl_context())
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ 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,12 +35,6 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
@@ -58,12 +52,6 @@ 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, abort
|
from flask import redirect, render_template, request, Response
|
||||||
|
|
||||||
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.OptionCounter)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
||||||
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,10 +142,7 @@ 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):
|
||||||
try:
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
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"])
|
||||||
@@ -200,10 +197,7 @@ 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):
|
||||||
try:
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
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
|
||||||
@@ -222,7 +216,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 OptionCounter options from their name pattern
|
# Detect and build ItemDict 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,12 +1,11 @@
|
|||||||
flask>=3.1.0
|
flask>=3.0.3
|
||||||
werkzeug>=3.1.3
|
werkzeug>=3.0.6
|
||||||
pony>=0.7.19
|
pony>=0.7.19
|
||||||
waitress>=3.0.2
|
waitress>=3.0.0
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.17
|
Flask-Compress>=1.15
|
||||||
Flask-Limiter>=3.12
|
Flask-Limiter>=3.8.0
|
||||||
bokeh>=3.6.3
|
bokeh>=3.5.2
|
||||||
markupsafe>=3.0.2
|
markupsafe>=2.1.5
|
||||||
Markdown>=3.7
|
Markdown>=3.7
|
||||||
mdx-breakless-lists>=1.0.1
|
mdx-breakless-lists>=1.0.1
|
||||||
setproctitle>=1.3.5
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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')) {
|
||||||
@@ -41,5 +42,10 @@ 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,4 +6,6 @@ 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();
|
||||||
});
|
});
|
||||||
|
|||||||
47
WebHostLib/static/assets/styleController.js
Normal file
47
WebHostLib/static/assets/styleController.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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,6 +25,7 @@ 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) {
|
||||||
@@ -48,5 +49,10 @@ 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,13 +36,6 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: calc(100vh - 110px);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -75,27 +75,6 @@
|
|||||||
#inventory-table img.acquired.green{ /*32CD32*/
|
#inventory-table img.acquired.green{ /*32CD32*/
|
||||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||||
}
|
}
|
||||||
#inventory-table img.acquired.hotpink{ /*FF69B4*/
|
|
||||||
filter: sepia(100%) hue-rotate(300deg) saturate(10);
|
|
||||||
}
|
|
||||||
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
|
|
||||||
filter: sepia(100%) hue-rotate(347deg) saturate(10);
|
|
||||||
}
|
|
||||||
#inventory-table img.acquired.crimson{ /*DB143B*/
|
|
||||||
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table span{
|
|
||||||
color: #B4B4A0;
|
|
||||||
font-size: 40px;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table span.acquired{
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.image-stack{
|
#inventory-table div.image-stack{
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{% 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>
|
||||||
@@ -14,4 +13,5 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -28,4 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -58,4 +57,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,15 +29,27 @@
|
|||||||
{% if patch.game == "Minecraft" %}
|
{% if patch.game == "Minecraft" %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APMC File...</a>
|
Download APMC File...</a>
|
||||||
|
{% elif patch.game == "Factorio" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Factorio Mod...</a>
|
||||||
|
{% elif patch.game == "Kingdom Hearts 2" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Kingdom Hearts 2 Mod...</a>
|
||||||
|
{% elif patch.game == "Ocarina of Time" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download APZ5 File...</a>
|
||||||
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APV6 File...</a>
|
Download APV6 File...</a>
|
||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
{% elif patch.game | supports_apdeltapatch %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
|
{% elif patch.game == "Final Fantasy Mystic Quest" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download APMQ File...</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,29 +5,26 @@
|
|||||||
<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 %}
|
|
||||||
|
|
||||||
{% block body %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% endblock %}
|
{% if messages %}
|
||||||
</main>
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
{% if show_footer %}
|
<div class="user-message">{{ message }}</div>
|
||||||
{% include "islandFooter.html" %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -111,19 +111,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionCounter(option_name, option) %}
|
{% macro ItemDict(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 (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|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 }}" />
|
||||||
@@ -222,7 +213,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomizeButton(option_name, option) %}
|
{% macro RandomizeButton(option_name, option) %}
|
||||||
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
|
<div class="randomize-button" data-tooltip="Toggle randomization 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,10 +93,8 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionCounter) and (
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
option.valid_keys or option.verify_item_name or option.verify_location_name
|
{{ inputs.ItemDict(option_name, option) }}
|
||||||
) %}
|
|
||||||
{{ 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) }}
|
||||||
@@ -135,10 +133,8 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionCounter) and (
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
option.valid_keys or option.verify_item_name or option.verify_location_name
|
{{ inputs.ItemDict(option_name, option) }}
|
||||||
) %}
|
|
||||||
{{ 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,6 +1,5 @@
|
|||||||
{% 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>
|
||||||
@@ -16,4 +15,5 @@
|
|||||||
{{ seed_error }}
|
{{ seed_error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% set show_footer = True %}
|
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -27,4 +26,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -99,52 +99,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
|
|
||||||
<div class="table-row">
|
|
||||||
{% if 'PrismBreak' in options %}
|
|
||||||
<div class="C1">
|
|
||||||
<div class="image-stack">
|
|
||||||
<div class="stack-front">
|
|
||||||
<div class="stack-top-left">
|
|
||||||
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
|
|
||||||
</div>
|
|
||||||
<div class="stack-top-right">
|
|
||||||
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
|
|
||||||
</div>
|
|
||||||
<div class="stack-bottum-left">
|
|
||||||
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'LockKeyAmadeus' in options %}
|
|
||||||
<div class="C2">
|
|
||||||
<div class="image-stack">
|
|
||||||
<div class="stack-front">
|
|
||||||
<div class="stack-top-left">
|
|
||||||
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
|
|
||||||
</div>
|
|
||||||
<div class="stack-top-right">
|
|
||||||
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
|
|
||||||
</div>
|
|
||||||
<div class="stack-bottum-left">
|
|
||||||
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
|
|
||||||
</div>
|
|
||||||
<div class="stack-bottum-right">
|
|
||||||
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if 'GateKeep' in options %}
|
|
||||||
<div class="C3">
|
|
||||||
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">❖</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table id="location-table">
|
<table id="location-table">
|
||||||
|
|||||||
@@ -29,8 +29,7 @@
|
|||||||
<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.<br/>
|
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
|
||||||
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,6 +1,5 @@
|
|||||||
{% 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>
|
||||||
@@ -51,4 +50,5 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'islandFooter.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% 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>
|
||||||
<noscript>
|
<meta http-equiv="refresh" content="1">
|
||||||
<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 %}
|
||||||
|
|
||||||
@@ -18,34 +15,5 @@
|
|||||||
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>
|
||||||
<script>
|
{% include 'islandFooter.html' %}
|
||||||
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,18 +113,9 @@
|
|||||||
{{ TextChoice(option_name, option) }}
|
{{ TextChoice(option_name, option) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionCounter(option_name, option, world) %}
|
{% macro ItemDict(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 (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|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,10 +83,8 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionCounter) and (
|
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
||||||
option.valid_keys or option.verify_item_name or option.verify_location_name
|
{{ inputs.ItemDict(option_name, option, world) }}
|
||||||
) %}
|
|
||||||
{{ 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) }}
|
||||||
@@ -102,7 +100,7 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="unsupported-option">
|
<div class="unsupported-option">
|
||||||
This option cannot be modified here. Please edit your .yaml file manually.
|
This option is not supported. Please edit your .yaml file manually.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1071,11 +1071,6 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
||||||
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
||||||
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
||||||
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
|
|
||||||
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
|
|
||||||
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
|
|
||||||
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
|
||||||
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
@@ -1123,9 +1118,6 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
timespinner_location_ids["Ancient Pyramid"] += [
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
1337237, 1337238, 1337239,
|
1337237, 1337238, 1337239,
|
||||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||||
if (slot_data["PyramidStart"]):
|
|
||||||
timespinner_location_ids["Ancient Pyramid"] += [
|
|
||||||
1337233, 1337234, 1337235]
|
|
||||||
|
|
||||||
display_data = {}
|
display_data = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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.just_fix_windows_console()
|
colorama.init()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -14,60 +14,23 @@
|
|||||||
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
|
||||||
# KivyMD theming parameters
|
<Label>:
|
||||||
theme_style: "Dark" # Light/Dark
|
color: "FFFFFF"
|
||||||
primary_palette: "Lightsteelblue" # Many options
|
<TabbedPanel>:
|
||||||
dynamic_scheme_name: "VIBRANT"
|
tab_width: root.width / app.tab_count
|
||||||
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>:
|
||||||
adaptive_height: True
|
text_size: self.width, None
|
||||||
theme_font_size: "Custom"
|
size_hint_y: None
|
||||||
font_size: "20dp"
|
height: self.texture_size[1]
|
||||||
|
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: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
|
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
|
||||||
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
|
||||||
@@ -86,7 +49,7 @@
|
|||||||
<HintLabel>:
|
<HintLabel>:
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
|
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)
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
@@ -163,12 +126,9 @@
|
|||||||
<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
|
||||||
@@ -187,43 +147,8 @@
|
|||||||
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: "30dp"
|
height: dp(30)
|
||||||
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
|
|
||||||
<MessageBoxLabel>:
|
|
||||||
valign: "middle"
|
|
||||||
halign: "center"
|
|
||||||
text_size: self.width, None
|
|
||||||
height: self.texture_size[1]
|
|
||||||
|
|||||||
161
data/launcher.kv
161
data/launcher.kv
@@ -1,161 +0,0 @@
|
|||||||
<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
|
|
||||||
@@ -365,14 +365,18 @@ request_handlers = {
|
|||||||
["PREFERRED_CORES"] = function (req)
|
["PREFERRED_CORES"] = function (req)
|
||||||
local res = {}
|
local res = {}
|
||||||
local preferred_cores = client.getconfig().PreferredCores
|
local preferred_cores = client.getconfig().PreferredCores
|
||||||
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
|
|
||||||
|
|
||||||
res["type"] = "PREFERRED_CORES_RESPONSE"
|
res["type"] = "PREFERRED_CORES_RESPONSE"
|
||||||
res["value"] = {}
|
res["value"] = {}
|
||||||
|
res["value"]["NES"] = preferred_cores.NES
|
||||||
while systems_enumerator:MoveNext() do
|
res["value"]["SNES"] = preferred_cores.SNES
|
||||||
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
|
res["value"]["GB"] = preferred_cores.GB
|
||||||
end
|
res["value"]["GBC"] = preferred_cores.GBC
|
||||||
|
res["value"]["DGB"] = preferred_cores.DGB
|
||||||
|
res["value"]["SGB"] = preferred_cores.SGB
|
||||||
|
res["value"]["PCE"] = preferred_cores.PCE
|
||||||
|
res["value"]["PCECD"] = preferred_cores.PCECD
|
||||||
|
res["value"]["SGX"] = preferred_cores.SGX
|
||||||
|
|
||||||
return res
|
return res
|
||||||
end,
|
end,
|
||||||
|
|||||||
462
data/lua/connector_ff1.lua
Normal file
462
data/lua/connector_ff1.lua
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
local socket = require("socket")
|
||||||
|
local json = require('json')
|
||||||
|
local math = require('math')
|
||||||
|
require("common")
|
||||||
|
|
||||||
|
local STATE_OK = "Ok"
|
||||||
|
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
||||||
|
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
||||||
|
local STATE_UNINITIALIZED = "Uninitialized"
|
||||||
|
|
||||||
|
local ITEM_INDEX = 0x03
|
||||||
|
local WEAPON_INDEX = 0x07
|
||||||
|
local ARMOR_INDEX = 0x0B
|
||||||
|
|
||||||
|
local goldLookup = {
|
||||||
|
[0x16C] = 10,
|
||||||
|
[0x16D] = 20,
|
||||||
|
[0x16E] = 25,
|
||||||
|
[0x16F] = 30,
|
||||||
|
[0x170] = 55,
|
||||||
|
[0x171] = 70,
|
||||||
|
[0x172] = 85,
|
||||||
|
[0x173] = 110,
|
||||||
|
[0x174] = 135,
|
||||||
|
[0x175] = 155,
|
||||||
|
[0x176] = 160,
|
||||||
|
[0x177] = 180,
|
||||||
|
[0x178] = 240,
|
||||||
|
[0x179] = 255,
|
||||||
|
[0x17A] = 260,
|
||||||
|
[0x17B] = 295,
|
||||||
|
[0x17C] = 300,
|
||||||
|
[0x17D] = 315,
|
||||||
|
[0x17E] = 330,
|
||||||
|
[0x17F] = 350,
|
||||||
|
[0x180] = 385,
|
||||||
|
[0x181] = 400,
|
||||||
|
[0x182] = 450,
|
||||||
|
[0x183] = 500,
|
||||||
|
[0x184] = 530,
|
||||||
|
[0x185] = 575,
|
||||||
|
[0x186] = 620,
|
||||||
|
[0x187] = 680,
|
||||||
|
[0x188] = 750,
|
||||||
|
[0x189] = 795,
|
||||||
|
[0x18A] = 880,
|
||||||
|
[0x18B] = 1020,
|
||||||
|
[0x18C] = 1250,
|
||||||
|
[0x18D] = 1455,
|
||||||
|
[0x18E] = 1520,
|
||||||
|
[0x18F] = 1760,
|
||||||
|
[0x190] = 1975,
|
||||||
|
[0x191] = 2000,
|
||||||
|
[0x192] = 2750,
|
||||||
|
[0x193] = 3400,
|
||||||
|
[0x194] = 4150,
|
||||||
|
[0x195] = 5000,
|
||||||
|
[0x196] = 5450,
|
||||||
|
[0x197] = 6400,
|
||||||
|
[0x198] = 6720,
|
||||||
|
[0x199] = 7340,
|
||||||
|
[0x19A] = 7690,
|
||||||
|
[0x19B] = 7900,
|
||||||
|
[0x19C] = 8135,
|
||||||
|
[0x19D] = 9000,
|
||||||
|
[0x19E] = 9300,
|
||||||
|
[0x19F] = 9500,
|
||||||
|
[0x1A0] = 9900,
|
||||||
|
[0x1A1] = 10000,
|
||||||
|
[0x1A2] = 12350,
|
||||||
|
[0x1A3] = 13000,
|
||||||
|
[0x1A4] = 13450,
|
||||||
|
[0x1A5] = 14050,
|
||||||
|
[0x1A6] = 14720,
|
||||||
|
[0x1A7] = 15000,
|
||||||
|
[0x1A8] = 17490,
|
||||||
|
[0x1A9] = 18010,
|
||||||
|
[0x1AA] = 19990,
|
||||||
|
[0x1AB] = 20000,
|
||||||
|
[0x1AC] = 20010,
|
||||||
|
[0x1AD] = 26000,
|
||||||
|
[0x1AE] = 45000,
|
||||||
|
[0x1AF] = 65000
|
||||||
|
}
|
||||||
|
|
||||||
|
local extensionConsumableLookup = {
|
||||||
|
[432] = 0x3C,
|
||||||
|
[436] = 0x3C,
|
||||||
|
[440] = 0x3C,
|
||||||
|
[433] = 0x3D,
|
||||||
|
[437] = 0x3D,
|
||||||
|
[441] = 0x3D,
|
||||||
|
[434] = 0x3E,
|
||||||
|
[438] = 0x3E,
|
||||||
|
[442] = 0x3E,
|
||||||
|
[435] = 0x3F,
|
||||||
|
[439] = 0x3F,
|
||||||
|
[443] = 0x3F
|
||||||
|
}
|
||||||
|
|
||||||
|
local noOverworldItemsLookup = {
|
||||||
|
[499] = 0x2B,
|
||||||
|
[500] = 0x12,
|
||||||
|
}
|
||||||
|
|
||||||
|
local consumableStacks = nil
|
||||||
|
local prevstate = ""
|
||||||
|
local curstate = STATE_UNINITIALIZED
|
||||||
|
local ff1Socket = nil
|
||||||
|
local frame = 0
|
||||||
|
|
||||||
|
local isNesHawk = false
|
||||||
|
|
||||||
|
|
||||||
|
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
|
||||||
|
local function defineMemoryFunctions()
|
||||||
|
local memDomain = {}
|
||||||
|
local domains = memory.getmemorydomainlist()
|
||||||
|
if domains[1] == "System Bus" then
|
||||||
|
--NesHawk
|
||||||
|
isNesHawk = true
|
||||||
|
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||||
|
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
|
||||||
|
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||||
|
elseif domains[1] == "WRAM" then
|
||||||
|
--QuickNES
|
||||||
|
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||||
|
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
|
||||||
|
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||||
|
end
|
||||||
|
return memDomain
|
||||||
|
end
|
||||||
|
|
||||||
|
local memDomain = defineMemoryFunctions()
|
||||||
|
|
||||||
|
local function StateOKForMainLoop()
|
||||||
|
memDomain.saveram()
|
||||||
|
local A = u8(0x102) -- Party Made
|
||||||
|
local B = u8(0x0FC)
|
||||||
|
local C = u8(0x0A3)
|
||||||
|
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
|
||||||
|
end
|
||||||
|
|
||||||
|
function generateLocationChecked()
|
||||||
|
memDomain.saveram()
|
||||||
|
data = uRange(0x01FF, 0x101)
|
||||||
|
data[0] = nil
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function setConsumableStacks()
|
||||||
|
memDomain.rom()
|
||||||
|
consumableStacks = {}
|
||||||
|
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
|
||||||
|
consumableStacks[0x35] = 1
|
||||||
|
consumableStacks[0x36] = u8(0x47400) + 1
|
||||||
|
consumableStacks[0x37] = u8(0x47401) + 1
|
||||||
|
consumableStacks[0x38] = u8(0x47402) + 1
|
||||||
|
consumableStacks[0x39] = u8(0x47403) + 1
|
||||||
|
consumableStacks[0x3A] = u8(0x47404) + 1
|
||||||
|
consumableStacks[0x3B] = u8(0x47405) + 1
|
||||||
|
consumableStacks[0x3C] = u8(0x47406) + 1
|
||||||
|
consumableStacks[0x3D] = u8(0x47407) + 1
|
||||||
|
consumableStacks[0x3E] = u8(0x47408) + 1
|
||||||
|
consumableStacks[0x3F] = u8(0x47409) + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function getEmptyWeaponSlots()
|
||||||
|
memDomain.saveram()
|
||||||
|
ret = {}
|
||||||
|
count = 1
|
||||||
|
slot1 = uRange(0x118, 0x4)
|
||||||
|
slot2 = uRange(0x158, 0x4)
|
||||||
|
slot3 = uRange(0x198, 0x4)
|
||||||
|
slot4 = uRange(0x1D8, 0x4)
|
||||||
|
for i,v in pairs(slot1) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x118 + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot2) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x158 + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot3) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x198 + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot4) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x1D8 + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ret
|
||||||
|
end
|
||||||
|
|
||||||
|
function getEmptyArmorSlots()
|
||||||
|
memDomain.saveram()
|
||||||
|
ret = {}
|
||||||
|
count = 1
|
||||||
|
slot1 = uRange(0x11C, 0x4)
|
||||||
|
slot2 = uRange(0x15C, 0x4)
|
||||||
|
slot3 = uRange(0x19C, 0x4)
|
||||||
|
slot4 = uRange(0x1DC, 0x4)
|
||||||
|
for i,v in pairs(slot1) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x11C + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot2) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x15C + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot3) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x19C + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i,v in pairs(slot4) do
|
||||||
|
if v == 0 then
|
||||||
|
ret[count] = 0x1DC + i
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ret
|
||||||
|
end
|
||||||
|
local function slice (tbl, s, e)
|
||||||
|
local pos, new = 1, {}
|
||||||
|
for i = s + 1, e do
|
||||||
|
new[pos] = tbl[i]
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
return new
|
||||||
|
end
|
||||||
|
function processBlock(block)
|
||||||
|
local msgBlock = block['messages']
|
||||||
|
if msgBlock ~= nil then
|
||||||
|
for i, v in pairs(msgBlock) do
|
||||||
|
if itemMessages[i] == nil then
|
||||||
|
local msg = {TTL=450, message=v, color=0xFFFF0000}
|
||||||
|
itemMessages[i] = msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local itemsBlock = block["items"]
|
||||||
|
memDomain.saveram()
|
||||||
|
isInGame = u8(0x102)
|
||||||
|
if itemsBlock ~= nil and isInGame ~= 0x00 then
|
||||||
|
if consumableStacks == nil then
|
||||||
|
setConsumableStacks()
|
||||||
|
end
|
||||||
|
memDomain.saveram()
|
||||||
|
-- print('ITEMBLOCK: ')
|
||||||
|
-- print(itemsBlock)
|
||||||
|
itemIndex = u8(ITEM_INDEX)
|
||||||
|
-- print('ITEMINDEX: '..itemIndex)
|
||||||
|
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
|
||||||
|
-- Minus the offset and add to the correct domain
|
||||||
|
local memoryLocation = v
|
||||||
|
if v >= 0x100 and v <= 0x114 then
|
||||||
|
-- This is a key item
|
||||||
|
memoryLocation = memoryLocation - 0x0E0
|
||||||
|
wU8(memoryLocation, 0x01)
|
||||||
|
elseif v >= 0x1E0 and v <= 0x1F2 then
|
||||||
|
-- This is a movement item
|
||||||
|
-- Minus Offset (0x100) - movement offset (0xE0)
|
||||||
|
memoryLocation = memoryLocation - 0x1E0
|
||||||
|
-- Canal is a flipped bit
|
||||||
|
if memoryLocation == 0x0C then
|
||||||
|
wU8(memoryLocation, 0x00)
|
||||||
|
else
|
||||||
|
wU8(memoryLocation, 0x01)
|
||||||
|
end
|
||||||
|
elseif v >= 0x1F3 and v <= 0x1F4 then
|
||||||
|
-- NoOverworld special items
|
||||||
|
memoryLocation = noOverworldItemsLookup[v]
|
||||||
|
wU8(memoryLocation, 0x01)
|
||||||
|
elseif v >= 0x16C and v <= 0x1AF then
|
||||||
|
-- This is a gold item
|
||||||
|
amountToAdd = goldLookup[v]
|
||||||
|
biggest = u8(0x01E)
|
||||||
|
medium = u8(0x01D)
|
||||||
|
smallest = u8(0x01C)
|
||||||
|
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
|
||||||
|
newValue = currentValue + amountToAdd
|
||||||
|
newBiggest = math.floor(newValue / 0x10000)
|
||||||
|
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
|
||||||
|
newSmallest = math.floor(math.fmod(newValue, 0x100))
|
||||||
|
wU8(0x01E, newBiggest)
|
||||||
|
wU8(0x01D, newMedium)
|
||||||
|
wU8(0x01C, newSmallest)
|
||||||
|
elseif v >= 0x115 and v <= 0x11B then
|
||||||
|
-- This is a regular consumable OR a shard
|
||||||
|
-- Minus Offset (0x100) + item offset (0x20)
|
||||||
|
memoryLocation = memoryLocation - 0x0E0
|
||||||
|
currentValue = u8(memoryLocation)
|
||||||
|
amountToAdd = consumableStacks[memoryLocation]
|
||||||
|
if currentValue < 99 then
|
||||||
|
wU8(memoryLocation, currentValue + amountToAdd)
|
||||||
|
end
|
||||||
|
elseif v >= 0x1B0 and v <= 0x1BB then
|
||||||
|
-- This is an extension consumable
|
||||||
|
memoryLocation = extensionConsumableLookup[v]
|
||||||
|
currentValue = u8(memoryLocation)
|
||||||
|
amountToAdd = consumableStacks[memoryLocation]
|
||||||
|
if currentValue < 99 then
|
||||||
|
value = currentValue + amountToAdd
|
||||||
|
if value > 99 then
|
||||||
|
value = 99
|
||||||
|
end
|
||||||
|
wU8(memoryLocation, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #itemsBlock > itemIndex then
|
||||||
|
wU8(ITEM_INDEX, #itemsBlock)
|
||||||
|
end
|
||||||
|
|
||||||
|
memDomain.saveram()
|
||||||
|
weaponIndex = u8(WEAPON_INDEX)
|
||||||
|
emptyWeaponSlots = getEmptyWeaponSlots()
|
||||||
|
lastUsedWeaponIndex = weaponIndex
|
||||||
|
-- print('WEAPON_INDEX: '.. weaponIndex)
|
||||||
|
memDomain.saveram()
|
||||||
|
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
|
||||||
|
if v >= 0x11C and v <= 0x143 then
|
||||||
|
-- Minus the offset and add to the correct domain
|
||||||
|
local itemValue = v - 0x11B
|
||||||
|
if #emptyWeaponSlots > 0 then
|
||||||
|
slot = table.remove(emptyWeaponSlots, 1)
|
||||||
|
wU8(slot, itemValue)
|
||||||
|
lastUsedWeaponIndex = weaponIndex + i
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if lastUsedWeaponIndex ~= weaponIndex then
|
||||||
|
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
|
||||||
|
end
|
||||||
|
memDomain.saveram()
|
||||||
|
armorIndex = u8(ARMOR_INDEX)
|
||||||
|
emptyArmorSlots = getEmptyArmorSlots()
|
||||||
|
lastUsedArmorIndex = armorIndex
|
||||||
|
-- print('ARMOR_INDEX: '.. armorIndex)
|
||||||
|
memDomain.saveram()
|
||||||
|
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
|
||||||
|
if v >= 0x144 and v <= 0x16B then
|
||||||
|
-- Minus the offset and add to the correct domain
|
||||||
|
local itemValue = v - 0x143
|
||||||
|
if #emptyArmorSlots > 0 then
|
||||||
|
slot = table.remove(emptyArmorSlots, 1)
|
||||||
|
wU8(slot, itemValue)
|
||||||
|
lastUsedArmorIndex = armorIndex + i
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if lastUsedArmorIndex ~= armorIndex then
|
||||||
|
wU8(ARMOR_INDEX, lastUsedArmorIndex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function receive()
|
||||||
|
l, e = ff1Socket:receive()
|
||||||
|
if e == 'closed' then
|
||||||
|
if curstate == STATE_OK then
|
||||||
|
print("Connection closed")
|
||||||
|
end
|
||||||
|
curstate = STATE_UNINITIALIZED
|
||||||
|
return
|
||||||
|
elseif e == 'timeout' then
|
||||||
|
print("timeout")
|
||||||
|
return
|
||||||
|
elseif e ~= nil then
|
||||||
|
print(e)
|
||||||
|
curstate = STATE_UNINITIALIZED
|
||||||
|
return
|
||||||
|
end
|
||||||
|
processBlock(json.decode(l))
|
||||||
|
|
||||||
|
-- Determine Message to send back
|
||||||
|
memDomain.rom()
|
||||||
|
local playerName = uRange(0x7BCBF, 0x41)
|
||||||
|
playerName[0] = nil
|
||||||
|
local retTable = {}
|
||||||
|
retTable["playerName"] = playerName
|
||||||
|
if StateOKForMainLoop() then
|
||||||
|
retTable["locations"] = generateLocationChecked()
|
||||||
|
end
|
||||||
|
msg = json.encode(retTable).."\n"
|
||||||
|
local ret, error = ff1Socket:send(msg)
|
||||||
|
if ret == nil then
|
||||||
|
print(error)
|
||||||
|
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
|
||||||
|
curstate = STATE_TENTATIVELY_CONNECTED
|
||||||
|
elseif curstate == STATE_TENTATIVELY_CONNECTED then
|
||||||
|
print("Connected!")
|
||||||
|
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
|
||||||
|
curstate = STATE_OK
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function main()
|
||||||
|
if not checkBizHawkVersion() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
server, error = socket.bind('localhost', 52980)
|
||||||
|
|
||||||
|
while true do
|
||||||
|
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
||||||
|
frame = frame + 1
|
||||||
|
drawMessages()
|
||||||
|
if not (curstate == prevstate) then
|
||||||
|
-- console.log("Current state: "..curstate)
|
||||||
|
prevstate = curstate
|
||||||
|
end
|
||||||
|
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||||
|
if (frame % 60 == 0) then
|
||||||
|
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
|
||||||
|
receive()
|
||||||
|
else
|
||||||
|
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
|
||||||
|
end
|
||||||
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
|
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
|
||||||
|
if (frame % 60 == 0) then
|
||||||
|
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
||||||
|
|
||||||
|
drawText(5, 8, "Waiting for client", 0xFFFF0000)
|
||||||
|
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
|
||||||
|
|
||||||
|
-- Advance so the messages are drawn
|
||||||
|
emu.frameadvance()
|
||||||
|
server:settimeout(2)
|
||||||
|
print("Attempting to connect")
|
||||||
|
local client, timeout = server:accept()
|
||||||
|
if timeout == nil then
|
||||||
|
-- print('Initial Connection Made')
|
||||||
|
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||||
|
ff1Socket = client
|
||||||
|
ff1Socket:settimeout(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
emu.frameadvance()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -51,9 +51,10 @@ requires:
|
|||||||
{%- for option_key, option in group_options.items() %}
|
{%- for option_key, option in group_options.items() %}
|
||||||
{{ option_key }}:
|
{{ option_key }}:
|
||||||
{%- if option.__doc__ %}
|
{%- if option.__doc__ %}
|
||||||
# {{ cleandoc(option.__doc__)
|
# {{ option.__doc__
|
||||||
| trim
|
| trim
|
||||||
| replace('\n', '\n# ')
|
| replace('\n\n', '\n \n')
|
||||||
|
| replace('\n ', '\n# ')
|
||||||
| indent(4, first=False)
|
| indent(4, first=False)
|
||||||
}}
|
}}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
BIN
data/sprites/ladx/Bowwow.bdiff
Normal file
BIN
data/sprites/ladx/Bowwow.bdiff
Normal file
Binary file not shown.
BIN
data/sprites/ladx/Bunny.bdiff
Normal file
BIN
data/sprites/ladx/Bunny.bdiff
Normal file
Binary file not shown.
BIN
data/sprites/ladx/Luigi.bdiff
Normal file
BIN
data/sprites/ladx/Luigi.bdiff
Normal file
Binary file not shown.
BIN
data/sprites/ladx/Mario.bdiff
Normal file
BIN
data/sprites/ladx/Mario.bdiff
Normal file
Binary file not shown.
BIN
data/sprites/ladx/Richard.bdiff
Normal file
BIN
data/sprites/ladx/Richard.bdiff
Normal file
Binary file not shown.
BIN
data/sprites/ladx/Tarin.bdiff
Normal file
BIN
data/sprites/ladx/Tarin.bdiff
Normal file
Binary file not shown.
@@ -45,9 +45,6 @@
|
|||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
/worlds/checksfinder/ @SunCatMC
|
/worlds/checksfinder/ @SunCatMC
|
||||||
|
|
||||||
# Civilization VI
|
|
||||||
/worlds/civ6/ @hesto2
|
|
||||||
|
|
||||||
# Clique
|
# Clique
|
||||||
/worlds/clique/ @ThePhar
|
/worlds/clique/ @ThePhar
|
||||||
|
|
||||||
@@ -87,9 +84,6 @@
|
|||||||
# Inscryption
|
# Inscryption
|
||||||
/worlds/inscryption/ @DrBibop @Glowbuzz
|
/worlds/inscryption/ @DrBibop @Glowbuzz
|
||||||
|
|
||||||
# Jak and Daxter: The Precursor Legacy
|
|
||||||
/worlds/jakanddaxter/ @massimilianodelliubaldini
|
|
||||||
|
|
||||||
# Kirby's Dream Land 3
|
# Kirby's Dream Land 3
|
||||||
/worlds/kdl3/ @Silvris
|
/worlds/kdl3/ @Silvris
|
||||||
|
|
||||||
@@ -160,9 +154,6 @@
|
|||||||
# Saving Princess
|
# Saving Princess
|
||||||
/worlds/saving_princess/ @LeonarthCG
|
/worlds/saving_princess/ @LeonarthCG
|
||||||
|
|
||||||
# shapez
|
|
||||||
/worlds/shapez/ @BlastSlimey
|
|
||||||
|
|
||||||
# Shivers
|
# Shivers
|
||||||
/worlds/shivers/ @GodlFire @korydondzila
|
/worlds/shivers/ @GodlFire @korydondzila
|
||||||
|
|
||||||
@@ -181,9 +172,6 @@
|
|||||||
# Super Mario 64
|
# Super Mario 64
|
||||||
/worlds/sm64ex/ @N00byKing
|
/worlds/sm64ex/ @N00byKing
|
||||||
|
|
||||||
# Super Mario Land 2: 6 Golden Coins
|
|
||||||
/worlds/marioland2/ @Alchav
|
|
||||||
|
|
||||||
# Super Mario World
|
# Super Mario World
|
||||||
/worlds/smw/ @PoryGone
|
/worlds/smw/ @PoryGone
|
||||||
|
|
||||||
@@ -193,6 +181,9 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -220,9 +211,6 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -238,10 +226,14 @@
|
|||||||
# 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
|
||||||
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
# compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
|
||||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||||
|
|
||||||
# Final Fantasy (1)
|
# Final Fantasy (1)
|
||||||
@@ -250,6 +242,15 @@
|
|||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
# /worlds/oot/
|
# /worlds/oot/
|
||||||
|
|
||||||
|
## Disabled Unmaintained Worlds
|
||||||
|
|
||||||
|
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
|
||||||
|
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
|
||||||
|
# documentation.
|
||||||
|
|
||||||
|
# Ori and the Blind Forest
|
||||||
|
# /worlds_disabled/oribf/
|
||||||
|
|
||||||
###################
|
###################
|
||||||
## Documentation ##
|
## Documentation ##
|
||||||
###################
|
###################
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# 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")
|
||||||
@@ -16,51 +13,30 @@ 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. Libraries for most modern languages and the spec for
|
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
|
||||||
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
|
to behave as expected are:
|
||||||
|
|
||||||
### 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
|
||||||
* Reconnect if the connection is unstable and lost while playing
|
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
|
||||||
|
* 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
|
||||||
|
|
||||||
Regarding items and locations, the game client must be able to handle these tasks:
|
Libraries for most modern languages and the spec for various packets can be found in the
|
||||||
|
[network protocol](/docs/network%20protocol.md) API reference document.
|
||||||
#### 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
|
||||||
|
|
||||||
@@ -68,94 +44,35 @@ 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/`.
|
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
|
||||||
|
following requirements:
|
||||||
|
|
||||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
* A folder within `/worlds/` that contains an `__init__.py`
|
||||||
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
* A `World` subclass where you create your world and define all of its rules
|
||||||
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
|
* A unique game name
|
||||||
check out [world maintainer.md](/docs/world%20maintainer.md).
|
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
|
||||||
|
definition
|
||||||
### Hard Requirements
|
* The game_info doc must follow the format `{language_code}_{game_name}.md`
|
||||||
|
|
||||||
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.
|
||||||
* An implementation of `create_item` that can create an item when called by either your code or by another process
|
* Create an item when `create_item` is called both by your code and externally
|
||||||
within Archipelago
|
|
||||||
* At least one `Region` for your player to start from (i.e. the Origin Region)
|
|
||||||
* The default name of this region is "Menu" but you may configure a different name with
|
|
||||||
[origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
|
|
||||||
* A non-zero number of locations, added to your regions
|
|
||||||
* 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.
|
|
||||||
|
|
||||||
### Encouraged Features
|
|
||||||
|
|
||||||
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
|
* 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 `Region` for your player with the name "Menu" to start from
|
||||||
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
|
* Create a non-zero number of locations and add them to your regions
|
||||||
* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226)
|
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
|
||||||
for better organization on the webhost
|
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific
|
||||||
* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223)
|
items, there are multiple ways to do so, but they should not be added to the multiworld itempool.
|
||||||
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
|
Notable caveats:
|
||||||
|
* The "Menu" region will always be considered the "start" for the player
|
||||||
These are behaviors or implementations that are known to cause various issues. Some of these points have notable
|
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the
|
||||||
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,11 +8,7 @@ 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
|
||||||
|
|
||||||
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
|
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.
|
||||||
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
|
||||||
@@ -22,19 +18,15 @@ 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
|
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
|
||||||
restrictive start
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 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
|
### I have multiple settings that change the item/location pool counts and need to balance them out
|
||||||
|
|
||||||
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
|
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.
|
||||||
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
|
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
|
||||||
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
|
||||||
@@ -47,8 +39,7 @@ 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
|
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
|
||||||
[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))]
|
||||||
```
|
```
|
||||||
@@ -57,86 +48,21 @@ 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
|
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.
|
||||||
**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.
|
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.
|
||||||
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
|
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:
|
||||||
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.
|
||||||
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
|
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.
|
||||||
regions are reached.
|
|
||||||
|
|
||||||
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
|
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.
|
||||||
if a specific region is reached during it.
|
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".
|
||||||
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
|
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.
|
||||||
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,
|
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.
|
||||||
and that some games have very complex access rules.
|
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.
|
||||||
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682)
|
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.
|
||||||
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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### What if my game has "missable" or "one-time-only" locations or region connections?
|
|
||||||
|
|
||||||
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
|
|
||||||
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
|
|
||||||
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
|
|
||||||
state change that AP logic acknowledges. No other actions or events can change reachability.
|
|
||||||
|
|
||||||
So when the game itself does not follow this assumption, the options are:
|
|
||||||
- Modify the game to make that location/connection repeatable
|
|
||||||
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
|
|
||||||
only the repeatable ways
|
|
||||||
- Don't generate the missable location/connection at all
|
|
||||||
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
|
|
||||||
- For locations, this may require game changes to remove the vanilla item if it affects logic
|
|
||||||
- Decide that resetting the save file is part of the game's logic, and warn players about that
|
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ 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]
|
||||||
|
|||||||
@@ -231,11 +231,11 @@ Sent to clients after a client requested this message be sent to them, more info
|
|||||||
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
||||||
|
|
||||||
#### Arguments
|
#### Arguments
|
||||||
| Name | Type | Notes |
|
| Name | Type | Notes |
|
||||||
| ---- |-------------| ----- |
|
| ---- | ---- | ----- |
|
||||||
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
|
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
|
||||||
| original_cmd | str \| None | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
|
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
|
||||||
| text | str | A descriptive message of the problem at hand. |
|
| text | str | A descriptive message of the problem at hand. |
|
||||||
|
|
||||||
##### PacketProblemType
|
##### PacketProblemType
|
||||||
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
|
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
|
||||||
@@ -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 | 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. |
|
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -551,14 +551,14 @@ In JSON this may look like:
|
|||||||
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
|
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import TypedDict
|
from typing import TypedDict, Optional
|
||||||
class JSONMessagePart(TypedDict):
|
class JSONMessagePart(TypedDict):
|
||||||
type: str | None
|
type: Optional[str]
|
||||||
text: str | None
|
text: Optional[str]
|
||||||
color: str | None # only available if type is a color
|
color: Optional[str] # only available if type is a color
|
||||||
flags: int | None # only available if type is an item_id or item_name
|
flags: Optional[int] # only available if type is an item_id or item_name
|
||||||
player: int | None # only available if type is either item or location
|
player: Optional[int] # only available if type is either item or location
|
||||||
hint_status: HintStatus | None # only available if type is hint_status
|
hint_status: Optional[HintStatus] # only available if type is hint_status
|
||||||
```
|
```
|
||||||
|
|
||||||
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
|
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
|
||||||
@@ -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, if the string is non-empty, it 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, this 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. |
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ within the world.
|
|||||||
### TextChoice
|
### TextChoice
|
||||||
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
|
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
|
||||||
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
|
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
|
||||||
class or within world, if necessary. Value for this class is `str | int` so if you need the value at a specified
|
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
|
||||||
point, `self.options.my_option.current_key` will always return a string.
|
point, `self.options.my_option.current_key` will always return a string.
|
||||||
|
|
||||||
### PlandoBosses
|
### PlandoBosses
|
||||||
@@ -352,15 +352,8 @@ 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
|
||||||
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
|
Like OptionDict, except this 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
|
||||||
|
|||||||
@@ -102,16 +102,17 @@ In worlds, this should only be used for the top level to avoid issues when upgra
|
|||||||
|
|
||||||
### Bool
|
### Bool
|
||||||
|
|
||||||
Since `bool` can not be subclassed, use the `settings.Bool` helper in a union to get a comment in host.yaml.
|
Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import settings
|
import settings
|
||||||
|
import typing
|
||||||
|
|
||||||
class MySettings(settings.Group):
|
class MySettings(settings.Group):
|
||||||
class MyBool(settings.Bool):
|
class MyBool(settings.Bool):
|
||||||
"""Doc string"""
|
"""Doc string"""
|
||||||
|
|
||||||
my_value: MyBool | bool = True
|
my_value: typing.Union[MyBool, bool] = True
|
||||||
```
|
```
|
||||||
|
|
||||||
### UserFilePath
|
### UserFilePath
|
||||||
@@ -133,15 +134,15 @@ Checks the file against [md5s](#md5s) by default.
|
|||||||
|
|
||||||
Resolves to an executable (varying file extension based on platform)
|
Resolves to an executable (varying file extension based on platform)
|
||||||
|
|
||||||
#### description: str | None
|
#### description: Optional\[str\]
|
||||||
|
|
||||||
Human-readable name to use in file browser
|
Human-readable name to use in file browser
|
||||||
|
|
||||||
#### copy_to: str | None
|
#### copy_to: Optional\[str\]
|
||||||
|
|
||||||
Instead of storing the path, copy the file.
|
Instead of storing the path, copy the file.
|
||||||
|
|
||||||
#### md5s: list[str | bytes]
|
#### md5s: List[Union[str, bytes]]
|
||||||
|
|
||||||
Provide md5 hashes as hex digests or raw bytes for automatic validation.
|
Provide md5 hashes as hex digests or raw bytes for automatic validation.
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,8 @@ found in the [general test directory](/test/general).
|
|||||||
## Defining World Tests
|
## Defining World Tests
|
||||||
|
|
||||||
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
|
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
|
||||||
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base
|
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
|
||||||
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import
|
for your world tests can be created in this file that you can then import into other modules.
|
||||||
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
|
|
||||||
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
|
|
||||||
|
|
||||||
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
|
|
||||||
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
|
|
||||||
|
|
||||||
### WorldTestBase
|
### WorldTestBase
|
||||||
|
|
||||||
@@ -26,7 +21,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
|
|||||||
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
|
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
|
||||||
options combinations.
|
options combinations.
|
||||||
|
|
||||||
Example `/worlds/<my_game>/test/bases.py`:
|
Example `/worlds/<my_game>/test/__init__.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from test.bases import WorldTestBase
|
from test.bases import WorldTestBase
|
||||||
@@ -54,7 +49,7 @@ with `test_`.
|
|||||||
Example `/worlds/<my_game>/test/test_chest_access.py`:
|
Example `/worlds/<my_game>/test/test_chest_access.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from .bases import MyGameTestBase
|
from . import MyGameTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestChestAccess(MyGameTestBase):
|
class TestChestAccess(MyGameTestBase):
|
||||||
@@ -78,58 +73,22 @@ When tests are run, this class will create a multiworld with a single player hav
|
|||||||
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
||||||
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
||||||
overridden. For more information on what methods are available to your class, check the
|
overridden. For more information on what methods are available to your class, check the
|
||||||
[WorldTestBase definition](/test/bases.py#L106).
|
[WorldTestBase definition](/test/bases.py#L104).
|
||||||
|
|
||||||
#### Alternatives to WorldTestBase
|
#### Alternatives to WorldTestBase
|
||||||
|
|
||||||
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
|
||||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
||||||
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
|
||||||
|
|
||||||
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
|
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
|
||||||
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run.
|
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
|
||||||
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`.
|
and set the working directory to the Archipelago directory which contains all the project files.
|
||||||
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
|
|
||||||
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
|
|
||||||
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
|
|
||||||
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
|
|
||||||
|
|
||||||
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
|
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
|
||||||
Your working directory should be the directory of your world in the worlds directory and the script should be the
|
Your working directory should be the directory of your world in the worlds directory and the script should be the
|
||||||
@@ -141,11 +100,3 @@ 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.
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ like entrance randomization in logic.
|
|||||||
|
|
||||||
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
||||||
|
|
||||||
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
|
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)),
|
||||||
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
||||||
|
|
||||||
### Entrances
|
### Entrances
|
||||||
@@ -331,7 +331,7 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
|
|||||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||||
|
|
||||||
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
|
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L301),
|
||||||
avoiding the need for indirect conditions at the expense of performance.
|
avoiding the need for indirect conditions at the expense of performance.
|
||||||
|
|
||||||
### Item Rules
|
### Item Rules
|
||||||
@@ -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, start inventory, item links) or when you call it from your own code
|
# this is called when AP wants to create an item by name (for plando) 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 or it's easier to apply the rules from data during
|
# (see below) is used, it's easier to apply the rules from data during
|
||||||
# location generation
|
# location generation or everything is in generate_basic
|
||||||
|
|
||||||
# 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),
|
||||||
|
|||||||
@@ -65,5 +65,5 @@ date, voting members and final result in the commit message.
|
|||||||
|
|
||||||
## Handling of Unmaintained Worlds
|
## Handling of Unmaintained Worlds
|
||||||
|
|
||||||
As long as worlds are known to work for the most part, they can stay included. Once the world becomes broken, it shall
|
As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
|
||||||
be deleted.
|
moved from `worlds/` to `worlds_disabled/`.
|
||||||
|
|||||||
@@ -50,15 +50,13 @@ 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, usable_exits: set[Entrance]):
|
def __init__(self, rng: random.Random, coupled: bool):
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
@@ -97,8 +95,7 @@ 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:
|
||||||
@@ -160,16 +157,17 @@ class ERPlacementState:
|
|||||||
def placed_regions(self) -> set[Region]:
|
def placed_regions(self) -> set[Region]:
|
||||||
return self.collection_state.reachable_regions[self.world.player]
|
return self.collection_state.reachable_regions[self.world.player]
|
||||||
|
|
||||||
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
|
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]:
|
||||||
if check_validity:
|
if check_validity:
|
||||||
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||||
placeable_randomized_exits = [ex for ex in usable_exits
|
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
|
||||||
if not ex.connected_region
|
placeable_randomized_exits = [connection for connection in blocked_connections
|
||||||
and ex in blocked_connections
|
if not connection.connected_region
|
||||||
and ex.is_valid_source_transition(self)]
|
and connection.is_valid_source_transition(self)]
|
||||||
else:
|
else:
|
||||||
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
||||||
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
|
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player)
|
||||||
|
for ex in region.exits if not ex.connected_region]
|
||||||
self.world.random.shuffle(placeable_randomized_exits)
|
self.world.random.shuffle(placeable_randomized_exits)
|
||||||
return placeable_randomized_exits
|
return placeable_randomized_exits
|
||||||
|
|
||||||
@@ -183,8 +181,7 @@ class ERPlacementState:
|
|||||||
self.placements.append(source_exit)
|
self.placements.append(source_exit)
|
||||||
self.pairings.append((source_exit.name, target_entrance.name))
|
self.pairings.append((source_exit.name, target_entrance.name))
|
||||||
|
|
||||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool:
|
||||||
usable_exits: set[Entrance]) -> bool:
|
|
||||||
copied_state = self.collection_state.copy()
|
copied_state = self.collection_state.copy()
|
||||||
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||||
# propagate back to the real multiworld.
|
# propagate back to the real multiworld.
|
||||||
@@ -201,9 +198,6 @@ class ERPlacementState:
|
|||||||
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
||||||
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||||
continue
|
continue
|
||||||
# make sure we are only paying attention to usable exits
|
|
||||||
if _exit not in usable_exits:
|
|
||||||
continue
|
|
||||||
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
||||||
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
||||||
# not want them to persist). can_reach is a close enough approximation most of the time.
|
# not want them to persist). can_reach is a close enough approximation most of the time.
|
||||||
@@ -268,19 +262,14 @@ 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,
|
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = 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. Because it attempts
|
in randomize_entrances. This should be done after setting the type and group of the entrance.
|
||||||
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
|
||||||
@@ -295,11 +284,8 @@ 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. naming is not a concern for coupling so we
|
# for 1-ways, the child region needs a target and coupling/naming is not a concern
|
||||||
# allow it to be user provided (and require it, to prevent an unhelpful assumed name in pairings)
|
target = child_region.create_er_target(child_region.name)
|
||||||
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
|
||||||
|
|
||||||
@@ -336,28 +322,10 @@ 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
|
||||||
|
|
||||||
if not er_targets:
|
|
||||||
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
|
||||||
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
|
||||||
if not exits:
|
|
||||||
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
|
||||||
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
|
||||||
if len(er_targets) != len(exits):
|
|
||||||
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
|
||||||
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
|
||||||
|
|
||||||
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
|
||||||
exits_set = set(exits)
|
|
||||||
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
|
|
||||||
for entrance in er_targets:
|
|
||||||
entrance_lookup.add(entrance)
|
|
||||||
|
|
||||||
# place the menu region and connected start region(s)
|
|
||||||
er_state.collection_state.update_reachable_regions(world.player)
|
|
||||||
|
|
||||||
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||||
# remove the placed targets from consideration
|
# remove the placed targets from consideration
|
||||||
@@ -369,37 +337,9 @@ 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)
|
||||||
for source_exit in placeable_exits:
|
for source_exit in placeable_exits:
|
||||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||||
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||||
@@ -410,10 +350,12 @@ 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(dead_end, require_new_exits, placeable_exits)
|
if (needs_speculative_sweep
|
||||||
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
and not er_state.test_speculative_connection(source_exit, target_entrance)):
|
||||||
continue
|
continue
|
||||||
do_placement(source_exit, target_entrance)
|
do_placement(source_exit, target_entrance)
|
||||||
return True
|
return True
|
||||||
@@ -465,6 +407,21 @@ def randomize_entrances(
|
|||||||
f"All unplaced entrances: {unplaced_entrances}\n"
|
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||||
f"All unplaced exits: {unplaced_exits}")
|
f"All unplaced exits: {unplaced_exits}")
|
||||||
|
|
||||||
|
if not er_targets:
|
||||||
|
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||||
|
if not exits:
|
||||||
|
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||||
|
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||||
|
if len(er_targets) != len(exits):
|
||||||
|
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||||
|
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||||
|
for entrance in er_targets:
|
||||||
|
entrance_lookup.add(entrance)
|
||||||
|
|
||||||
|
# place the menu region and connected start region(s)
|
||||||
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
|
||||||
# stage 1 - try to place all the non-dead-end entrances
|
# stage 1 - try to place all the non-dead-end entrances
|
||||||
while entrance_lookup.others:
|
while entrance_lookup.others:
|
||||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ MinVersion={#min_windows}
|
|||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}";
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
|
||||||
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"
|
||||||
@@ -84,9 +83,18 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
|
|||||||
Type: dirifempty; Name: "{app}"
|
Type: dirifempty; Name: "{app}"
|
||||||
|
|
||||||
[InstallDelete]
|
[InstallDelete]
|
||||||
Type: files; Name: "{app}\*.exe"
|
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
|
||||||
|
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: files; Name: "{app}\data\lua\connector_ff1.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"
|
||||||
@@ -213,11 +221,6 @@ 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: "";
|
||||||
@@ -253,17 +256,3 @@ 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;
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
|
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
||||||
python_classes = Test
|
python_classes = Test
|
||||||
python_functions = test
|
python_functions = test
|
||||||
testpaths =
|
testpaths =
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
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.3
|
jellyfish>=1.1.0
|
||||||
jinja2>=3.1.6
|
jinja2>=3.1.4
|
||||||
schema>=0.7.7
|
schema>=0.7.7
|
||||||
kivy>=2.3.1
|
kivy>=2.3.0
|
||||||
bsdiff4>=1.2.6
|
bsdiff4>=1.2.4
|
||||||
platformdirs>=4.3.6
|
platformdirs>=4.2.2
|
||||||
certifi>=2025.4.26
|
certifi>=2024.12.14
|
||||||
cython>=3.0.12
|
cython>=3.0.11
|
||||||
cymem>=2.0.11
|
cymem>=2.0.8
|
||||||
orjson>=3.10.15
|
orjson>=3.10.7
|
||||||
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
|
|
||||||
|
|||||||
59
settings.py
59
settings.py
@@ -10,10 +10,9 @@ import sys
|
|||||||
import types
|
import types
|
||||||
import typing
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Iterator, Sequence
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union
|
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_settings", "fmt_doc", "no_gui",
|
"get_settings", "fmt_doc", "no_gui",
|
||||||
@@ -24,7 +23,7 @@ __all__ = [
|
|||||||
|
|
||||||
no_gui = False
|
no_gui = False
|
||||||
skip_autosave = False
|
skip_autosave = False
|
||||||
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change
|
_world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
|
||||||
_world_settings_name_cache_updated = False
|
_world_settings_name_cache_updated = False
|
||||||
_lock = Lock()
|
_lock = Lock()
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ def fmt_doc(cls: type, level: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class Group:
|
class Group:
|
||||||
_type_cache: ClassVar[dict[str, Any] | None] = None
|
_type_cache: ClassVar[Optional[Dict[str, Any]]] = None
|
||||||
_dumping: bool = False
|
_dumping: bool = False
|
||||||
_has_attr: bool = False
|
_has_attr: bool = False
|
||||||
_changed: bool = False
|
_changed: bool = False
|
||||||
@@ -107,7 +106,7 @@ class Group:
|
|||||||
self.__dict__.values()))
|
self.__dict__.values()))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_type_hints(cls) -> dict[str, Any]:
|
def get_type_hints(cls) -> Dict[str, Any]:
|
||||||
"""Returns resolved type hints for the class"""
|
"""Returns resolved type hints for the class"""
|
||||||
if cls._type_cache is None:
|
if cls._type_cache is None:
|
||||||
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||||
@@ -125,10 +124,10 @@ class Group:
|
|||||||
return self[key]
|
return self[key]
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def items(self) -> list[tuple[str, Any]]:
|
def items(self) -> List[Tuple[str, Any]]:
|
||||||
return [(key, getattr(self, key)) for key in self]
|
return [(key, getattr(self, key)) for key in self]
|
||||||
|
|
||||||
def update(self, dct: dict[str, Any]) -> None:
|
def update(self, dct: Dict[str, Any]) -> None:
|
||||||
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
|
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
|
||||||
f"{dct.__class__.__name__} instead of dict."
|
f"{dct.__class__.__name__} instead of dict."
|
||||||
|
|
||||||
@@ -197,7 +196,7 @@ class Group:
|
|||||||
warnings.warn(f"{self.__class__.__name__}.{k} "
|
warnings.warn(f"{self.__class__.__name__}.{k} "
|
||||||
f"assigned from incompatible type {type(v).__name__}")
|
f"assigned from incompatible type {type(v).__name__}")
|
||||||
|
|
||||||
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]:
|
def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
|
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
|
||||||
for name in self if not args or name in args
|
for name in self if not args or name in args
|
||||||
@@ -212,7 +211,7 @@ class Group:
|
|||||||
f.write(f"{indent}{yaml_line}")
|
f.write(f"{indent}{yaml_line}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None:
|
def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
|
||||||
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
|
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
|
||||||
|
|
||||||
# lazy construction of yaml Dumper to avoid loading Utils early
|
# lazy construction of yaml Dumper to avoid loading Utils early
|
||||||
@@ -224,7 +223,7 @@ class Group:
|
|||||||
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
|
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
|
||||||
from yaml import ScalarNode
|
from yaml import ScalarNode
|
||||||
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
|
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
|
||||||
pairs = cast(list[tuple[ScalarNode, Any]], res.value)
|
pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
|
||||||
for k, v in pairs:
|
for k, v in pairs:
|
||||||
k.style = None # remove quotes from keys
|
k.style = None # remove quotes from keys
|
||||||
return res
|
return res
|
||||||
@@ -330,9 +329,9 @@ class Path(str):
|
|||||||
"""Marks the file as required and opens a file browser when missing"""
|
"""Marks the file as required and opens a file browser when missing"""
|
||||||
is_exe: bool = False
|
is_exe: bool = False
|
||||||
"""Special cross-platform handling for executables"""
|
"""Special cross-platform handling for executables"""
|
||||||
description: str | None = None
|
description: Optional[str] = None
|
||||||
"""Title to display when browsing for the file"""
|
"""Title to display when browsing for the file"""
|
||||||
copy_to: str | None = None
|
copy_to: Optional[str] = None
|
||||||
"""If not None, copy to AP folder instead of linking it"""
|
"""If not None, copy to AP folder instead of linking it"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -340,7 +339,7 @@ class Path(str):
|
|||||||
"""Overload and raise to validate input files from browse"""
|
"""Overload and raise to validate input files from browse"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def browse(self: T, **kwargs: Any) -> T | None:
|
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||||
"""Opens a file browser to search for the file"""
|
"""Opens a file browser to search for the file"""
|
||||||
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
|
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
|
||||||
|
|
||||||
@@ -370,12 +369,12 @@ class _LocalPath(str):
|
|||||||
class FilePath(Path):
|
class FilePath(Path):
|
||||||
# path to a file
|
# path to a file
|
||||||
|
|
||||||
md5s: ClassVar[list[str | bytes]] = []
|
md5s: ClassVar[List[Union[str, bytes]]] = []
|
||||||
"""MD5 hashes for default validator."""
|
"""MD5 hashes for default validator."""
|
||||||
|
|
||||||
def browse(self: T,
|
def browse(self: T,
|
||||||
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\
|
filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
|
||||||
-> T | None:
|
-> Optional[T]:
|
||||||
from Utils import open_filename, is_windows
|
from Utils import open_filename, is_windows
|
||||||
if not filetypes:
|
if not filetypes:
|
||||||
if self.is_exe:
|
if self.is_exe:
|
||||||
@@ -440,7 +439,7 @@ class FilePath(Path):
|
|||||||
class FolderPath(Path):
|
class FolderPath(Path):
|
||||||
# path to a folder
|
# path to a folder
|
||||||
|
|
||||||
def browse(self: T, **kwargs: Any) -> T | None:
|
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||||
from Utils import open_directory
|
from Utils import open_directory
|
||||||
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
|
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
|
||||||
if res:
|
if res:
|
||||||
@@ -598,16 +597,16 @@ class ServerOptions(Group):
|
|||||||
OFF = 0
|
OFF = 0
|
||||||
ON = 1
|
ON = 1
|
||||||
|
|
||||||
host: str | None = None
|
host: Optional[str] = None
|
||||||
port: int = 38281
|
port: int = 38281
|
||||||
password: str | None = None
|
password: Optional[str] = None
|
||||||
multidata: str | None = None
|
multidata: Optional[str] = None
|
||||||
savefile: str | None = None
|
savefile: Optional[str] = None
|
||||||
disable_save: bool = False
|
disable_save: bool = False
|
||||||
loglevel: str = "info"
|
loglevel: str = "info"
|
||||||
logtime: bool = False
|
logtime: bool = False
|
||||||
server_password: ServerPassword | None = None
|
server_password: Optional[ServerPassword] = None
|
||||||
disable_item_cheat: DisableItemCheat | bool = False
|
disable_item_cheat: Union[DisableItemCheat, bool] = False
|
||||||
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
|
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
|
||||||
hint_cost: HintCost = HintCost(10)
|
hint_cost: HintCost = HintCost(10)
|
||||||
release_mode: ReleaseMode = ReleaseMode("auto")
|
release_mode: ReleaseMode = ReleaseMode("auto")
|
||||||
@@ -703,7 +702,7 @@ does nothing if not found
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
sni_path: SNIPath = SNIPath("SNI")
|
sni_path: SNIPath = SNIPath("SNI")
|
||||||
snes_rom_start: SnesRomStart | bool = True
|
snes_rom_start: Union[SnesRomStart, bool] = True
|
||||||
|
|
||||||
|
|
||||||
class BizHawkClientOptions(Group):
|
class BizHawkClientOptions(Group):
|
||||||
@@ -722,7 +721,7 @@ class BizHawkClientOptions(Group):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
|
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
|
||||||
rom_start: RomStart | bool = True
|
rom_start: Union[RomStart, bool] = True
|
||||||
|
|
||||||
|
|
||||||
# Top-level group with lazy loading of worlds
|
# Top-level group with lazy loading of worlds
|
||||||
@@ -734,7 +733,7 @@ class Settings(Group):
|
|||||||
sni_options: SNIOptions = SNIOptions()
|
sni_options: SNIOptions = SNIOptions()
|
||||||
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
|
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
|
||||||
|
|
||||||
_filename: str | None = None
|
_filename: Optional[str] = None
|
||||||
|
|
||||||
def __getattribute__(self, key: str) -> Any:
|
def __getattribute__(self, key: str) -> Any:
|
||||||
if key.startswith("_") or key in self.__class__.__dict__:
|
if key.startswith("_") or key in self.__class__.__dict__:
|
||||||
@@ -788,7 +787,7 @@ class Settings(Group):
|
|||||||
|
|
||||||
return super().__getattribute__(key)
|
return super().__getattribute__(key)
|
||||||
|
|
||||||
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8?
|
def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
|
||||||
super().__init__()
|
super().__init__()
|
||||||
if location:
|
if location:
|
||||||
from Utils import parse_yaml
|
from Utils import parse_yaml
|
||||||
@@ -822,7 +821,7 @@ class Settings(Group):
|
|||||||
import atexit
|
import atexit
|
||||||
atexit.register(autosave)
|
atexit.register(autosave)
|
||||||
|
|
||||||
def save(self, location: str | None = None) -> None: # as above
|
def save(self, location: Optional[str] = None) -> None: # as above
|
||||||
from Utils import parse_yaml
|
from Utils import parse_yaml
|
||||||
location = location or self._filename
|
location = location or self._filename
|
||||||
assert location, "No file specified"
|
assert location, "No file specified"
|
||||||
@@ -855,7 +854,7 @@ class Settings(Group):
|
|||||||
super().dump(f, level)
|
super().dump(f, level)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str | None:
|
def filename(self) -> Optional[str]:
|
||||||
return self._filename
|
return self._filename
|
||||||
|
|
||||||
|
|
||||||
@@ -868,7 +867,7 @@ def get_settings() -> Settings:
|
|||||||
if not res:
|
if not res:
|
||||||
from Utils import user_path, local_path
|
from Utils import user_path, local_path
|
||||||
filenames = ("options.yaml", "host.yaml")
|
filenames = ("options.yaml", "host.yaml")
|
||||||
locations: list[str] = []
|
locations: List[str] = []
|
||||||
if os.path.join(os.getcwd()) != local_path():
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
locations += filenames # use files from cwd only if it's not the local_path
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
locations += [user_path(filename) for filename in filenames]
|
locations += [user_path(filename) for filename in filenames]
|
||||||
|
|||||||
65
setup.py
65
setup.py
@@ -1,23 +1,25 @@
|
|||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import sysconfig
|
import sysconfig
|
||||||
import threading
|
|
||||||
import urllib.request
|
|
||||||
import warnings
|
import warnings
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Iterable, Sequence
|
import urllib.request
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from hashlib import sha3_512
|
from hashlib import sha3_512
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
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==8.0.0'
|
requirement = 'cx-Freeze==7.2.0'
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
try:
|
try:
|
||||||
@@ -58,17 +60,19 @@ from Cython.Build import cythonize
|
|||||||
|
|
||||||
|
|
||||||
# On Python < 3.10 LogicMixin is not currently supported.
|
# On Python < 3.10 LogicMixin is not currently supported.
|
||||||
non_apworlds: set[str] = {
|
non_apworlds: Set[str] = {
|
||||||
"A Link to the Past",
|
"A Link to the Past",
|
||||||
"Adventure",
|
"Adventure",
|
||||||
"ArchipIDLE",
|
"ArchipIDLE",
|
||||||
"Archipelago",
|
"Archipelago",
|
||||||
"Clique",
|
"Clique",
|
||||||
|
"Final Fantasy",
|
||||||
"Lufia II Ancient Cave",
|
"Lufia II Ancient Cave",
|
||||||
"Meritous",
|
"Meritous",
|
||||||
"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",
|
||||||
@@ -144,13 +148,13 @@ def download_SNI() -> None:
|
|||||||
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
||||||
|
|
||||||
|
|
||||||
signtool: str | None
|
signtool: Optional[str]
|
||||||
if os.path.exists("X:/pw.txt"):
|
if os.path.exists("X:/pw.txt"):
|
||||||
print("Using signtool")
|
print("Using signtool")
|
||||||
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 /td sha256 /tr http://timestamp.digicert.com/ '
|
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||||
else:
|
else:
|
||||||
signtool = None
|
signtool = None
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ def remove_sprites_from_folder(folder: Path) -> None:
|
|||||||
os.remove(folder / file)
|
os.remove(folder / file)
|
||||||
|
|
||||||
|
|
||||||
def _threaded_hash(filepath: str | Path) -> str:
|
def _threaded_hash(filepath: Union[str, Path]) -> str:
|
||||||
hasher = sha3_512()
|
hasher = sha3_512()
|
||||||
hasher.update(open(filepath, "rb").read())
|
hasher.update(open(filepath, "rb").read())
|
||||||
return base64.b85encode(hasher.digest()).decode()
|
return base64.b85encode(hasher.digest()).decode()
|
||||||
@@ -252,7 +256,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
self.libfolder = Path(self.buildfolder, "lib")
|
self.libfolder = Path(self.buildfolder, "lib")
|
||||||
self.library = Path(self.libfolder, "library.zip")
|
self.library = Path(self.libfolder, "library.zip")
|
||||||
|
|
||||||
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None:
|
def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
|
||||||
folder = self.buildfolder
|
folder = self.buildfolder
|
||||||
if subpath:
|
if subpath:
|
||||||
folder /= subpath
|
folder /= subpath
|
||||||
@@ -371,7 +375,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
assert not non_apworlds - set(AutoWorldRegister.world_types), \
|
assert not non_apworlds - set(AutoWorldRegister.world_types), \
|
||||||
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
|
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
|
||||||
folders_to_remove: list[str] = []
|
folders_to_remove: List[str] = []
|
||||||
|
disabled_worlds_folder = "worlds_disabled"
|
||||||
|
for entry in os.listdir(disabled_worlds_folder):
|
||||||
|
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
|
||||||
|
folders_to_remove.append(entry)
|
||||||
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
|
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
|
||||||
for worldname, worldtype in AutoWorldRegister.world_types.items():
|
for worldname, worldtype in AutoWorldRegister.world_types.items():
|
||||||
if worldname not in non_apworlds:
|
if worldname not in non_apworlds:
|
||||||
@@ -439,12 +447,12 @@ class AppImageCommand(setuptools.Command):
|
|||||||
("app-exec=", None, "The application to run inside the image."),
|
("app-exec=", None, "The application to run inside the image."),
|
||||||
("yes", "y", 'Answer "yes" to all questions.'),
|
("yes", "y", 'Answer "yes" to all questions.'),
|
||||||
]
|
]
|
||||||
build_folder: Path | None
|
build_folder: Optional[Path]
|
||||||
dist_file: Path | None
|
dist_file: Optional[Path]
|
||||||
app_dir: Path | None
|
app_dir: Optional[Path]
|
||||||
app_name: str
|
app_name: str
|
||||||
app_exec: Path | None
|
app_exec: Optional[Path]
|
||||||
app_icon: Path | None # source file
|
app_icon: Optional[Path] # source file
|
||||||
app_id: str # lower case name, used for icon and .desktop
|
app_id: str # lower case name, used for icon and .desktop
|
||||||
yes: bool
|
yes: bool
|
||||||
|
|
||||||
@@ -481,12 +489,12 @@ tmp="${{exe#*/}}"
|
|||||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||||
exe="{default_exe.parent}/$exe"
|
exe="{default_exe.parent}/$exe"
|
||||||
fi
|
fi
|
||||||
export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib"
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
|
||||||
$APPDIR/$exe "$@"
|
$APPDIR/$exe "$@"
|
||||||
""")
|
""")
|
||||||
launcher_filename.chmod(0o755)
|
launcher_filename.chmod(0o755)
|
||||||
|
|
||||||
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None:
|
def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
|
||||||
assert self.app_dir, "Invalid app_dir"
|
assert self.app_dir, "Invalid app_dir"
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -549,7 +557,7 @@ $APPDIR/$exe "$@"
|
|||||||
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
|
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
|
||||||
|
|
||||||
|
|
||||||
def find_libs(*args: str) -> Sequence[tuple[str, str]]:
|
def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
|
||||||
"""Try to find system libraries to be included."""
|
"""Try to find system libraries to be included."""
|
||||||
if not args:
|
if not args:
|
||||||
return []
|
return []
|
||||||
@@ -557,7 +565,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
|
|||||||
arch = build_arch.replace('_', '-')
|
arch = build_arch.replace('_', '-')
|
||||||
libc = 'libc6' # we currently don't support musl
|
libc = 'libc6' # we currently don't support musl
|
||||||
|
|
||||||
def parse(line: str) -> tuple[tuple[str, str, str], str]:
|
def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
|
||||||
lib, path = line.strip().split(' => ')
|
lib, path = line.strip().split(' => ')
|
||||||
lib, typ = lib.split(' ', 1)
|
lib, typ = lib.split(' ', 1)
|
||||||
for test_arch in ('x86-64', 'i386', 'aarch64'):
|
for test_arch in ('x86-64', 'i386', 'aarch64'):
|
||||||
@@ -582,8 +590,8 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
|
|||||||
k: v for k, v in (parse(line) for line in data if "=>" in line)
|
k: v for k, v in (parse(line) for line in data if "=>" in line)
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_lib(lib: str, arch: str, libc: str) -> str | None:
|
def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
|
||||||
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache")
|
cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
|
||||||
for k, v in cache.items():
|
for k, v in cache.items():
|
||||||
if k == (lib, arch, libc):
|
if k == (lib, arch, libc):
|
||||||
return v
|
return v
|
||||||
@@ -592,7 +600,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
|
|||||||
return v
|
return v
|
||||||
return None
|
return None
|
||||||
|
|
||||||
res: list[tuple[str, str]] = []
|
res: List[Tuple[str, str]] = []
|
||||||
for arg in args:
|
for arg in args:
|
||||||
# try exact match, empty libc, empty arch, empty arch and libc
|
# try exact match, empty libc, empty arch, empty arch and libc
|
||||||
file = find_lib(arg, arch, libc)
|
file = find_lib(arg, arch, libc)
|
||||||
@@ -621,13 +629,12 @@ cx_Freeze.setup(
|
|||||||
ext_modules=cythonize("_speedups.pyx"),
|
ext_modules=cythonize("_speedups.pyx"),
|
||||||
options={
|
options={
|
||||||
"build_exe": {
|
"build_exe": {
|
||||||
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
|
"packages": ["worlds", "kivy", "cymem", "websockets"],
|
||||||
"includes": [],
|
"includes": [],
|
||||||
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
||||||
"pandas"],
|
"pandas", "zstandard"],
|
||||||
"zip_includes": [],
|
|
||||||
"zip_include_packages": ["*"],
|
"zip_include_packages": ["*"],
|
||||||
"zip_exclude_packages": ["worlds", "sc2", "kivymd"],
|
"zip_exclude_packages": ["worlds", "sc2"],
|
||||||
"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": ["*."],
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
self.multiworld.game[self.player] = self.game
|
self.multiworld.game[self.player] = self.game
|
||||||
self.multiworld.player_name = {self.player: "Tester"}
|
self.multiworld.player_name = {self.player: "Tester"}
|
||||||
self.multiworld.set_seed(seed)
|
self.multiworld.set_seed(seed)
|
||||||
|
self.multiworld.state = CollectionState(self.multiworld)
|
||||||
random.seed(self.multiworld.seed)
|
random.seed(self.multiworld.seed)
|
||||||
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
|
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
@@ -167,7 +168,6 @@ class WorldTestBase(unittest.TestCase):
|
|||||||
1: option.from_any(self.options.get(name, option.default))
|
1: option.from_any(self.options.get(name, option.default))
|
||||||
})
|
})
|
||||||
self.multiworld.set_options(args)
|
self.multiworld.set_options(args)
|
||||||
self.multiworld.state = CollectionState(self.multiworld)
|
|
||||||
self.world = self.multiworld.worlds[self.player]
|
self.world = self.multiworld.worlds[self.player]
|
||||||
for step in gen_steps:
|
for step in gen_steps:
|
||||||
call_all(self.multiworld, step)
|
call_all(self.multiworld, step)
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ def run_locations_benchmark():
|
|||||||
multiworld.game[1] = game
|
multiworld.game[1] = game
|
||||||
multiworld.player_name = {1: "Tester"}
|
multiworld.player_name = {1: "Tester"}
|
||||||
multiworld.set_seed(0)
|
multiworld.set_seed(0)
|
||||||
|
multiworld.state = CollectionState(multiworld)
|
||||||
args = argparse.Namespace()
|
args = argparse.Namespace()
|
||||||
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
|
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
|
||||||
setattr(args, name, {
|
setattr(args, name, {
|
||||||
1: option.from_any(getattr(option, "default"))
|
1: option.from_any(getattr(option, "default"))
|
||||||
})
|
})
|
||||||
multiworld.set_options(args)
|
multiworld.set_options(args)
|
||||||
multiworld.state = CollectionState(multiworld)
|
|
||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
for step in self.gen_steps:
|
for step in self.gen_steps:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
|
|||||||
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
||||||
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
||||||
multiworld.set_seed(seed)
|
multiworld.set_seed(seed)
|
||||||
|
multiworld.state = CollectionState(multiworld)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for player, world_type in enumerate(worlds, 1):
|
for player, world_type in enumerate(worlds, 1):
|
||||||
for key, option in world_type.options_dataclass.type_hints.items():
|
for key, option in world_type.options_dataclass.type_hints.items():
|
||||||
@@ -56,7 +57,6 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
|
|||||||
updated_options[player] = option.from_any(option.default)
|
updated_options[player] = option.from_any(option.default)
|
||||||
setattr(args, key, updated_options)
|
setattr(args, key, updated_options)
|
||||||
multiworld.set_options(args)
|
multiworld.set_options(args)
|
||||||
multiworld.state = CollectionState(multiworld)
|
|
||||||
for step in steps:
|
for step in steps:
|
||||||
call_all(multiworld, step)
|
call_all(multiworld, step)
|
||||||
return multiworld
|
return multiworld
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from typing import Callable
|
|
||||||
import unittest
|
import unittest
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
@@ -35,7 +34,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_creator: Callable[[str, int, MultiWorld], Region] = Region):
|
region_type: type[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
|
||||||
@@ -45,7 +44,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_creator(name, 1, multiworld)
|
region = region_type(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}")
|
||||||
|
|
||||||
@@ -66,10 +65,8 @@ 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, usable_exits=exits_set)
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
||||||
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:
|
||||||
@@ -89,10 +86,8 @@ 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, usable_exits=exits_set)
|
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
|
||||||
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:
|
||||||
@@ -104,30 +99,6 @@ 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):
|
||||||
@@ -177,7 +148,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
|
|||||||
e.randomization_group = 1
|
e.randomization_group = 1
|
||||||
e.connect(r2)
|
e.connect(r2)
|
||||||
|
|
||||||
disconnect_entrance_for_randomization(e, one_way_target_name="foo")
|
disconnect_entrance_for_randomization(e)
|
||||||
|
|
||||||
self.assertIsNone(e.connected_region)
|
self.assertIsNone(e.connected_region)
|
||||||
self.assertEqual([], r1.entrances)
|
self.assertEqual([], r1.entrances)
|
||||||
@@ -187,22 +158,10 @@ 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("foo", r2.entrances[0].name)
|
self.assertEqual("r2", 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)
|
||||||
@@ -212,7 +171,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, "foo")
|
disconnect_entrance_for_randomization(e, 2)
|
||||||
|
|
||||||
self.assertIsNone(e.connected_region)
|
self.assertIsNone(e.connected_region)
|
||||||
self.assertEqual([], r1.entrances)
|
self.assertEqual([], r1.entrances)
|
||||||
@@ -222,7 +181,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("foo", r2.entrances[0].name)
|
self.assertEqual("r2", 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)
|
||||||
|
|
||||||
@@ -259,7 +218,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_coupled(self):
|
def test_coupling(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)
|
||||||
@@ -277,36 +236,6 @@ 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()
|
||||||
@@ -466,7 +395,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_creator=CustomRegion)
|
generate_disconnected_region_grid(multiworld, 5, region_type=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,39 +47,13 @@ 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):
|
||||||
len_item_id_to_name = len(world_type.item_id_to_name)
|
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
|
||||||
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):
|
||||||
len_location_id_to_name = len(world_type.location_id_to_name)
|
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
|
||||||
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"""
|
||||||
|
|||||||
@@ -53,22 +53,6 @@ class TestImplemented(unittest.TestCase):
|
|||||||
if failed_world_loads:
|
if failed_world_loads:
|
||||||
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
||||||
|
|
||||||
def test_prefill_items(self):
|
|
||||||
"""Test that every world can reach every location from allstate before pre_fill."""
|
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
|
||||||
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
|
|
||||||
with self.subTest(gamename):
|
|
||||||
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
|
|
||||||
"set_rules", "connect_entrances", "generate_basic"))
|
|
||||||
allstate = multiworld.get_all_state(False)
|
|
||||||
locations = multiworld.get_locations()
|
|
||||||
reachable = multiworld.get_reachable_locations(allstate)
|
|
||||||
unreachable = [location for location in locations if location not in reachable]
|
|
||||||
|
|
||||||
self.assertTrue(not unreachable,
|
|
||||||
f"Locations were not reachable with all state before prefill: "
|
|
||||||
f"{unreachable}. Seed: {multiworld.seed}")
|
|
||||||
|
|
||||||
def test_explicit_indirect_conditions_spheres(self):
|
def test_explicit_indirect_conditions_spheres(self):
|
||||||
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
|
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
|
||||||
indirect conditions"""
|
indirect conditions"""
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from argparse import Namespace
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -13,31 +8,12 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_create_item(self):
|
def test_create_item(self):
|
||||||
"""Test that a world can successfully create all items in its datapackage"""
|
"""Test that a world can successfully create all items in its datapackage"""
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
|
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
|
||||||
proxy_world = multiworld.worlds[1]
|
|
||||||
for item_name in world_type.item_name_to_id:
|
for item_name in world_type.item_name_to_id:
|
||||||
test_state = CollectionState(multiworld)
|
|
||||||
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
|
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
|
||||||
item = proxy_world.create_item(item_name)
|
item = proxy_world.create_item(item_name)
|
||||||
|
|
||||||
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
|
|
||||||
self.assertEqual(item.name, item_name)
|
self.assertEqual(item.name, item_name)
|
||||||
|
|
||||||
if item.advancement:
|
|
||||||
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
|
|
||||||
test_state.collect(item, True)
|
|
||||||
|
|
||||||
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
|
|
||||||
test_state.remove(item)
|
|
||||||
|
|
||||||
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
|
|
||||||
"Item Collect -> Remove should restore empty state.")
|
|
||||||
else:
|
|
||||||
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
|
|
||||||
# Non-Advancement should not modify state.
|
|
||||||
test_state.collect(item)
|
|
||||||
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
|
|
||||||
|
|
||||||
def test_item_name_group_has_valid_item(self):
|
def test_item_name_group_has_valid_item(self):
|
||||||
"""Test that all item name groups contain valid items. """
|
"""Test that all item name groups contain valid items. """
|
||||||
# This cannot test for Event names that you may have declared for logic, only sendable Items.
|
# This cannot test for Event names that you may have declared for logic, only sendable Items.
|
||||||
@@ -87,47 +63,6 @@ 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`"""
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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))
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.")
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
|
|||||||
for step in self.test_steps:
|
for step in self.test_steps:
|
||||||
with self.subTest("Step", step=step):
|
with self.subTest("Step", step=step):
|
||||||
call_all(multiworld, step)
|
call_all(multiworld, step)
|
||||||
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
|
self.assertTrue(multiworld.get_all_state(False, True))
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ class Client:
|
|||||||
"version": {
|
"version": {
|
||||||
"class": "Version",
|
"class": "Version",
|
||||||
"major": 0,
|
"major": 0,
|
||||||
"minor": 6,
|
"minor": 4,
|
||||||
"build": 0,
|
"build": 6,
|
||||||
},
|
},
|
||||||
"items_handling": 0,
|
"items_handling": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["copy", "delete"]
|
__all__ = ["copy", "delete"]
|
||||||
|
|
||||||
|
|
||||||
_new_worlds: dict[str, str] = {}
|
_new_worlds: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def copy(src: str, dst: str) -> None:
|
def copy(src: str, dst: str) -> None:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user