mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-25 00:23:32 -07:00
Compare commits
2 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9adca5b04b | ||
|
|
182d58e847 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -9,14 +9,12 @@ on:
|
|||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
- 'worlds/*/archipelago.json'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
- 'worlds/*/archipelago.json'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -24,7 +22,7 @@ env:
|
|||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
# we check the sha256 and require manual intervention if it was updated.
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
APPIMAGETOOL_VERSION: continuous
|
APPIMAGETOOL_VERSION: continuous
|
||||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
APPIMAGE_RUNTIME_VERSION: continuous
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
|
|||||||
154
.github/workflows/docker.yml
vendored
154
.github/workflows/docker.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
name: Build and Publish Docker Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "**"
|
|
||||||
- "!docs/**"
|
|
||||||
- "!deploy/**"
|
|
||||||
- "!setup.py"
|
|
||||||
- "!.gitignore"
|
|
||||||
- "!.github/workflows/**"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
branches:
|
|
||||||
- "*"
|
|
||||||
tags:
|
|
||||||
- "v?[0-9]+.[0-9]+.[0-9]*"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
image-name: ${{ steps.image.outputs.name }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
package-name: ${{ steps.package.outputs.name }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set lowercase image name
|
|
||||||
id: image
|
|
||||||
run: |
|
|
||||||
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set package name
|
|
||||||
id: package
|
|
||||||
run: |
|
|
||||||
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch,enable={{is_not_default_branch}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=raw,value=nightly,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Compute final tags
|
|
||||||
id: final-tags
|
|
||||||
run: |
|
|
||||||
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
|
|
||||||
|
|
||||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
|
||||||
tag="${{ github.ref_name }}"
|
|
||||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
|
|
||||||
# Check if latest is already in tags to avoid duplicates
|
|
||||||
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
|
|
||||||
tags+=("$full_latest")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set multiline output
|
|
||||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
|
||||||
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
|
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: prepare
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
suffix: amd64
|
|
||||||
cache-scope: amd64
|
|
||||||
- platform: arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
suffix: arm64
|
|
||||||
cache-scope: arm64
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Compute suffixed tags
|
|
||||||
id: tags
|
|
||||||
run: |
|
|
||||||
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
|
|
||||||
suffixed=()
|
|
||||||
for t in "${tags[@]}"; do
|
|
||||||
suffixed+=("$t-${{ matrix.suffix }}")
|
|
||||||
done
|
|
||||||
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.tags.outputs.tags }}
|
|
||||||
labels: ${{ needs.prepare.outputs.labels }}
|
|
||||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
|
||||||
provenance: false
|
|
||||||
|
|
||||||
manifest:
|
|
||||||
needs: [prepare, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Create and push multi-arch manifest
|
|
||||||
run: |
|
|
||||||
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
|
|
||||||
|
|
||||||
for tag in "${tag_array[@]}"; do
|
|
||||||
docker manifest create "$tag" \
|
|
||||||
"$tag-amd64" \
|
|
||||||
"$tag-arm64"
|
|
||||||
|
|
||||||
docker manifest push "$tag"
|
|
||||||
done
|
|
||||||
1
.github/workflows/label-pull-requests.yml
vendored
1
.github/workflows/label-pull-requests.yml
vendored
@@ -12,6 +12,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
name: 'Apply content-based labels'
|
name: 'Apply content-based labels'
|
||||||
|
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v5
|
- uses: actions/labeler@v5
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ env:
|
|||||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
# we check the sha256 and require manual intervention if it was updated.
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
APPIMAGETOOL_VERSION: continuous
|
APPIMAGETOOL_VERSION: continuous
|
||||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
APPIMAGE_RUNTIME_VERSION: continuous
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
|
|
||||||
<module name="Archipelago" />
|
|
||||||
<option name="ENV_FILES" value="" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="PARENT_ENVS" value="true" />
|
|
||||||
<envs>
|
|
||||||
<env name="PYTHONUNBUFFERED" value="1" />
|
|
||||||
</envs>
|
|
||||||
<option name="SDK_HOME" value="" />
|
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
|
|
||||||
<option name="IS_MODULE_SDK" value="true" />
|
|
||||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
||||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
||||||
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
|
|
||||||
<option name="PARAMETERS" value="\"Build APWorlds\"" />
|
|
||||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
||||||
<option name="EMULATE_TERMINAL" value="false" />
|
|
||||||
<option name="MODULE_MODE" value="false" />
|
|
||||||
<option name="REDIRECT_INPUT" value="false" />
|
|
||||||
<option name="INPUT_FILE" value="" />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -261,7 +261,6 @@ class MultiWorld():
|
|||||||
"local_items": set(item_link.get("local_items", [])),
|
"local_items": set(item_link.get("local_items", [])),
|
||||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
"skip_if_solo": item_link.get("skip_if_solo", False),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _name, item_link in item_links.items():
|
for _name, item_link in item_links.items():
|
||||||
@@ -285,8 +284,6 @@ class MultiWorld():
|
|||||||
|
|
||||||
for group_name, item_link in item_links.items():
|
for group_name, item_link in item_links.items():
|
||||||
game = item_link["game"]
|
game = item_link["game"]
|
||||||
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
|
|
||||||
continue
|
|
||||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||||
|
|
||||||
group["item_pool"] = item_link["item_pool"]
|
group["item_pool"] = item_link["item_pool"]
|
||||||
@@ -1346,7 +1343,8 @@ class Region:
|
|||||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
|
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||||
|
location_type: Optional[type[Location]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||||
location names to address.
|
location names to address.
|
||||||
@@ -1434,8 +1432,8 @@ class Region:
|
|||||||
entrance.connect(self)
|
entrance.connect(self)
|
||||||
return entrance
|
return entrance
|
||||||
|
|
||||||
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||||
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
|
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||||
"""
|
"""
|
||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
@@ -1443,7 +1441,7 @@ class Region:
|
|||||||
created entrances will be named "self.name -> connecting_region"
|
created entrances will be named "self.name -> connecting_region"
|
||||||
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
||||||
"""
|
"""
|
||||||
if not isinstance(exits, Mapping):
|
if not isinstance(exits, Dict):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
return [
|
return [
|
||||||
self.connect(
|
self.connect(
|
||||||
@@ -1857,9 +1855,6 @@ class Spoiler:
|
|||||||
Utils.__version__, self.multiworld.seed))
|
Utils.__version__, self.multiworld.seed))
|
||||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||||
if self.multiworld.players > 1:
|
|
||||||
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
|
|
||||||
outfile.write('Total Location Count: %d\n' % loc_count)
|
|
||||||
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
||||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||||
|
|
||||||
@@ -1868,9 +1863,6 @@ class Spoiler:
|
|||||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||||
|
|
||||||
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
|
|
||||||
outfile.write('Location Count: %d\n' % loc_count)
|
|
||||||
|
|
||||||
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
|
|
||||||
|
|||||||
@@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
|
|
||||||
server_url = urllib.parse.urlparse(address)
|
server_url = urllib.parse.urlparse(address)
|
||||||
if server_url.username:
|
if server_url.username:
|
||||||
ctx.username = urllib.parse.unquote(server_url.username)
|
ctx.username = server_url.username
|
||||||
if server_url.password:
|
if server_url.password:
|
||||||
ctx.password = urllib.parse.unquote(server_url.password)
|
ctx.password = server_url.password
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
def reconnect_hint() -> str:
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
|
|||||||
4
Fill.py
4
Fill.py
@@ -129,10 +129,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
for i, location in enumerate(placements))
|
for i, location in enumerate(placements))
|
||||||
for (i, location, unsafe) in swap_attempts:
|
for (i, location, unsafe) in swap_attempts:
|
||||||
placed_item = location.item
|
placed_item = location.item
|
||||||
if item_to_place == placed_item:
|
|
||||||
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
|
|
||||||
# itself.
|
|
||||||
continue
|
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
# number of times we will swap an individual item to prevent this
|
# number of times we will swap an individual item to prevent this
|
||||||
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||||
|
|||||||
21
Generate.py
21
Generate.py
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
|||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse(argv: list[str] | None = None):
|
def mystery_argparse():
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
defaults = settings.generator
|
defaults = settings.generator
|
||||||
@@ -57,7 +57,7 @@ def mystery_argparse(argv: list[str] | None = None):
|
|||||||
parser.add_argument("--spoiler_only", action="store_true",
|
parser.add_argument("--spoiler_only", action="store_true",
|
||||||
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.skip_output and args.spoiler_only:
|
if args.skip_output and args.spoiler_only:
|
||||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
@@ -486,22 +486,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
f"which is not enabled.")
|
f"which is not enabled.")
|
||||||
games = requirements.get("game", {})
|
|
||||||
for game, version in games.items():
|
|
||||||
if game not in AutoWorldRegister.world_types:
|
|
||||||
continue
|
|
||||||
if not version:
|
|
||||||
raise Exception(f"Invalid version for game {game}: {version}.")
|
|
||||||
if isinstance(version, str):
|
|
||||||
version = {"min": version}
|
|
||||||
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
|
|
||||||
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
|
|
||||||
f"however world is of version "
|
|
||||||
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
|
||||||
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
|
|
||||||
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
|
|
||||||
f"however world is of version "
|
|
||||||
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
|
||||||
ret = argparse.Namespace()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||||
|
|||||||
9
KH1Client.py
Normal file
9
KH1Client.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
if __name__ == '__main__':
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
Utils.init_logging("KH1Client", exception_logger="Client")
|
||||||
|
|
||||||
|
from worlds.kh1.Client import launch
|
||||||
|
launch()
|
||||||
8
KH2Client.py
Normal file
8
KH2Client.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import ModuleUpdate
|
||||||
|
import Utils
|
||||||
|
from worlds.kh2.Client import launch
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||||
|
launch()
|
||||||
@@ -3,6 +3,9 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
@@ -23,14 +26,16 @@ 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 . import LinksAwakeningWorld
|
from worlds.ladx import LinksAwakeningWorld
|
||||||
from .Common import BASE_ID as LABaseID
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from .GpsTracker import GpsTracker
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
from .TrackerConsts import storage_key
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from .ItemTracker import ItemTracker
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from .LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from .Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
from .Tracker import LocationTracker, MagpieBridge, Check
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -407,10 +412,10 @@ class LinksAwakeningClient():
|
|||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||||
|
|
||||||
item_id -= LABaseID
|
item_id -= LABaseID
|
||||||
# The player name table only goes up to 101, so don't go past that
|
# The player name table only goes up to 100, so don't go past that
|
||||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||||
if from_player > 101:
|
if from_player > 100:
|
||||||
from_player = 101
|
from_player = 100
|
||||||
|
|
||||||
next_index += 1
|
next_index += 1
|
||||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||||
@@ -755,44 +760,42 @@ def run_game(romfile: str) -> None:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||||
|
|
||||||
def launch(*launch_args):
|
async def main():
|
||||||
async def main():
|
parser = get_base_parser(description="Link's Awakening Client.")
|
||||||
parser = get_base_parser(description="Link's Awakening Client.")
|
parser.add_argument("--url", help="Archipelago connection url")
|
||||||
parser.add_argument("--url", help="Archipelago connection url")
|
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
||||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
help='Path to a .apladx Archipelago Binary Patch file')
|
||||||
help='Path to a .apladx Archipelago Binary Patch file')
|
|
||||||
|
|
||||||
args = parser.parse_args(launch_args)
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
import Patch
|
import Patch
|
||||||
logger.info("patch file was supplied - creating rom...")
|
logger.info("patch file was supplied - creating rom...")
|
||||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||||
if "server" in meta and not args.connect:
|
if "server" in meta and not args.connect:
|
||||||
args.connect = meta["server"]
|
args.connect = meta["server"]
|
||||||
logger.info(f"wrote rom file to {rom_file}")
|
logger.info(f"wrote rom file to {rom_file}")
|
||||||
|
|
||||||
|
|
||||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||||
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
# TODO: nothing about the lambda about has to be in a lambda
|
# TODO: nothing about the lambda about has to be in a lambda
|
||||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
ctx.run_gui()
|
ctx.run_gui()
|
||||||
ctx.run_cli()
|
ctx.run_cli()
|
||||||
|
|
||||||
# Down below run_gui so that we get errors out of the process
|
# Down below run_gui so that we get errors out of the process
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
run_game(rom_file)
|
run_game(rom_file)
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
colorama.just_fix_windows_console()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
11
Main.py
11
Main.py
@@ -54,17 +54,12 @@ 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)
|
||||||
|
|
||||||
world_classes = 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())))
|
||||||
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
|
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
|
|
||||||
|
|
||||||
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}}: "
|
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
|
||||||
f"Items: {len(cls.item_names):{item_count}} | "
|
|
||||||
f"Locations: {len(cls.location_names):{location_count}}")
|
f"Locations: {len(cls.location_names):{location_count}}")
|
||||||
|
|
||||||
del item_count, location_count
|
del item_count, location_count
|
||||||
|
|||||||
211
MultiServer.py
211
MultiServer.py
@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
|
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -50,15 +50,6 @@ from BaseClasses import ItemClassification
|
|||||||
min_client_version = Version(0, 5, 0)
|
min_client_version = Version(0, 5, 0)
|
||||||
colorama.just_fix_windows_console()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
no_version = Version(0, 0, 0)
|
|
||||||
assert isinstance(no_version, tuple) # assert immutable
|
|
||||||
|
|
||||||
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
|
|
||||||
server_max_window_bits=11,
|
|
||||||
client_max_window_bits=11,
|
|
||||||
compress_settings={"memLevel": 4},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
try:
|
try:
|
||||||
@@ -134,31 +125,8 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
__slots__ = (
|
version = Version(0, 0, 0)
|
||||||
"__weakref__",
|
tags: typing.List[str]
|
||||||
"version",
|
|
||||||
"auth",
|
|
||||||
"team",
|
|
||||||
"slot",
|
|
||||||
"send_index",
|
|
||||||
"tags",
|
|
||||||
"messageprocessor",
|
|
||||||
"ctx",
|
|
||||||
"remote_items",
|
|
||||||
"remote_start_inventory",
|
|
||||||
"no_items",
|
|
||||||
"no_locations",
|
|
||||||
"no_text",
|
|
||||||
)
|
|
||||||
|
|
||||||
version: Version
|
|
||||||
auth: bool
|
|
||||||
team: int | None
|
|
||||||
slot: int | None
|
|
||||||
send_index: int
|
|
||||||
tags: list[str]
|
|
||||||
messageprocessor: ClientMessageProcessor
|
|
||||||
ctx: weakref.ref[Context]
|
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
@@ -167,7 +135,6 @@ class Client(Endpoint):
|
|||||||
|
|
||||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.version = no_version
|
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
self.slot = None
|
self.slot = None
|
||||||
@@ -175,11 +142,6 @@ class Client(Endpoint):
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.messageprocessor = client_message_processor(ctx, self)
|
self.messageprocessor = client_message_processor(ctx, self)
|
||||||
self.ctx = weakref.ref(ctx)
|
self.ctx = weakref.ref(ctx)
|
||||||
self.remote_items = False
|
|
||||||
self.remote_start_inventory = False
|
|
||||||
self.no_items = False
|
|
||||||
self.no_locations = False
|
|
||||||
self.no_text = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def items_handling(self):
|
def items_handling(self):
|
||||||
@@ -217,7 +179,6 @@ class Context:
|
|||||||
"release_mode": str,
|
"release_mode": str,
|
||||||
"remaining_mode": str,
|
"remaining_mode": str,
|
||||||
"collect_mode": str,
|
"collect_mode": str,
|
||||||
"countdown_mode": str,
|
|
||||||
"item_cheat": bool,
|
"item_cheat": bool,
|
||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
@@ -247,8 +208,8 @@ class Context:
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||||
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
super(Context, self).__init__()
|
super(Context, self).__init__()
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
@@ -281,7 +242,6 @@ class Context:
|
|||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
self.countdown_mode: str = countdown_mode
|
|
||||||
self.item_cheat = item_cheat
|
self.item_cheat = item_cheat
|
||||||
self.exit_event = asyncio.Event()
|
self.exit_event = asyncio.Event()
|
||||||
self.client_activity_timers: typing.Dict[
|
self.client_activity_timers: typing.Dict[
|
||||||
@@ -667,7 +627,6 @@ class Context:
|
|||||||
"server_password": self.server_password, "password": self.password,
|
"server_password": self.server_password, "password": self.password,
|
||||||
"release_mode": self.release_mode,
|
"release_mode": self.release_mode,
|
||||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||||
"countdown_mode": self.countdown_mode,
|
|
||||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -702,7 +661,6 @@ class Context:
|
|||||||
self.release_mode = savedata["game_options"]["release_mode"]
|
self.release_mode = savedata["game_options"]["release_mode"]
|
||||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||||
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
|
|
||||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||||
self.compatibility = savedata["game_options"]["compatibility"]
|
self.compatibility = savedata["game_options"]["compatibility"]
|
||||||
|
|
||||||
@@ -1177,13 +1135,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||||
status: HintStatus | None = None) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given item id or name, with a given status.
|
|
||||||
If status is None (which is the default value), an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1199,39 +1152,25 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
else:
|
else:
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
|
new_status = auto_status
|
||||||
hint_status = status # Assign again because we're in a for loop
|
|
||||||
if found:
|
if found:
|
||||||
hint_status = HintStatus.HINT_FOUND
|
new_status = HintStatus.HINT_FOUND
|
||||||
elif hint_status is None:
|
elif item_flags & ItemClassification.trap:
|
||||||
if item_flags & ItemClassification.trap:
|
new_status = HintStatus.HINT_AVOID
|
||||||
hint_status = HintStatus.HINT_AVOID
|
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||||
else:
|
item_flags, new_status))
|
||||||
hint_status = HintStatus.HINT_PRIORITY
|
|
||||||
|
|
||||||
hints.append(
|
|
||||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
|
||||||
)
|
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||||
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
|
|
||||||
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
|
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
|
||||||
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
-> typing.List[Hint]:
|
||||||
"""
|
|
||||||
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
|
|
||||||
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
|
||||||
"""
|
|
||||||
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||||
if prev_hint:
|
if prev_hint:
|
||||||
return [prev_hint]
|
return [prev_hint]
|
||||||
@@ -1241,16 +1180,13 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
|||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
|
new_status = auto_status
|
||||||
if found:
|
if found:
|
||||||
status = HintStatus.HINT_FOUND
|
new_status = HintStatus.HINT_FOUND
|
||||||
elif status is None:
|
elif item_flags & ItemClassification.trap:
|
||||||
if item_flags & ItemClassification.trap:
|
new_status = HintStatus.HINT_AVOID
|
||||||
status = HintStatus.HINT_AVOID
|
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
||||||
else:
|
new_status)]
|
||||||
status = HintStatus.HINT_PRIORITY
|
|
||||||
|
|
||||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -1364,8 +1300,7 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
argname += "=" + parameter.default
|
argname += "=" + parameter.default
|
||||||
argtext += argname
|
argtext += argname
|
||||||
argtext += " "
|
argtext += " "
|
||||||
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
|
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
||||||
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def _cmd_help(self):
|
def _cmd_help(self):
|
||||||
@@ -1394,6 +1329,19 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
class CommonCommandProcessor(CommandProcessor):
|
class CommonCommandProcessor(CommandProcessor):
|
||||||
ctx: Context
|
ctx: Context
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
"""List all current options. Warning: lists password."""
|
"""List all current options. Warning: lists password."""
|
||||||
self.output("Current options:")
|
self.output("Current options:")
|
||||||
@@ -1535,23 +1483,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
" You can ask the server admin for a /collect")
|
" You can ask the server admin for a /collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
if self.ctx.countdown_mode == "disabled" or \
|
|
||||||
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
|
|
||||||
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
@@ -1679,6 +1610,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1704,9 +1636,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||||
hints = []
|
hints = []
|
||||||
elif not for_location:
|
elif not for_location:
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
game = self.ctx.games[self.client.slot]
|
game = self.ctx.games[self.client.slot]
|
||||||
@@ -1726,18 +1658,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(
|
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
|
||||||
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
|
||||||
)
|
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.output(response)
|
self.output(response)
|
||||||
@@ -2015,7 +1945,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
|
|
||||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
@@ -2307,19 +2238,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_release(self, player_name: str) -> bool:
|
def _cmd_release(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
@@ -2441,9 +2359,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||||
else: # item name or id
|
else: # item name or id
|
||||||
hints = collect_hints(self.ctx, team, slot, item)
|
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
@@ -2477,14 +2395,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
|
|
||||||
if usable:
|
if usable:
|
||||||
if isinstance(location, int):
|
if isinstance(location, int):
|
||||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||||
hints = []
|
hints = []
|
||||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
||||||
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
else:
|
else:
|
||||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||||
|
HintStatus.HINT_UNSPECIFIED)
|
||||||
if hints:
|
if hints:
|
||||||
self.ctx.notify_hints(team, hints)
|
self.ctx.notify_hints(team, hints)
|
||||||
else:
|
else:
|
||||||
@@ -2512,11 +2433,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
elif value_type == str and option_name.endswith("password"):
|
elif value_type == str and option_name.endswith("password"):
|
||||||
def value_type(input_text: str):
|
def value_type(input_text: str):
|
||||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
||||||
elif option_name == "countdown_mode":
|
|
||||||
valid_values = {"enabled", "disabled", "auto"}
|
|
||||||
if option_value.lower() not in valid_values:
|
|
||||||
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
|
||||||
return False
|
|
||||||
elif value_type == str and option_name.endswith("mode"):
|
elif value_type == str and option_name.endswith("mode"):
|
||||||
valid_values = {"goal", "enabled", "disabled"}
|
valid_values = {"goal", "enabled", "disabled"}
|
||||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||||
@@ -2604,13 +2520,6 @@ def parse_args() -> argparse.Namespace:
|
|||||||
goal: !collect can be used after goal completion
|
goal: !collect can be used after goal completion
|
||||||
auto-enabled: !collect is available and automatically triggered on goal completion
|
auto-enabled: !collect is available and automatically triggered on goal completion
|
||||||
''')
|
''')
|
||||||
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
|
|
||||||
choices=['enabled', 'disabled', "auto"], help='''\
|
|
||||||
Select !countdown Accessibility. (default: %(default)s)
|
|
||||||
enabled: !countdown is always available
|
|
||||||
disabled: !countdown is never available
|
|
||||||
auto: !countdown is available for rooms with less than 30 players
|
|
||||||
''')
|
|
||||||
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
||||||
choices=['enabled', 'disabled', "goal"], help='''\
|
choices=['enabled', 'disabled', "goal"], help='''\
|
||||||
Select !remaining Accessibility. (default: %(default)s)
|
Select !remaining Accessibility. (default: %(default)s)
|
||||||
@@ -2676,7 +2585,7 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
args.countdown_mode, args.remaining_mode,
|
args.remaining_mode,
|
||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
@@ -2711,13 +2620,7 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
||||||
|
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
|
||||||
functools.partial(server, ctx=ctx),
|
|
||||||
host=ctx.host,
|
|
||||||
port=ctx.port,
|
|
||||||
ssl=ssl_context,
|
|
||||||
extensions=[server_per_message_deflate_factory],
|
|
||||||
)
|
|
||||||
ip = args.host if args.host else Utils.get_public_ipv4()
|
ip = args.host if args.host else Utils.get_public_ipv4()
|
||||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||||
|
|||||||
@@ -174,8 +174,6 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
|||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
__slots__ = ("socket",)
|
|
||||||
|
|
||||||
socket: "ServerConnection"
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
|
|||||||
14
Options.py
14
Options.py
@@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet):
|
|||||||
|
|
||||||
|
|
||||||
class StartInventory(ItemDict):
|
class StartInventory(ItemDict):
|
||||||
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
"""Start with these items."""
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1388,7 +1388,7 @@ class StartInventory(ItemDict):
|
|||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
|
"""Start with these items and don't place them in the world.
|
||||||
|
|
||||||
The game decides what the replacement items will be.
|
The game decides what the replacement items will be.
|
||||||
"""
|
"""
|
||||||
@@ -1446,7 +1446,6 @@ class ItemLinks(OptionList):
|
|||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)],
|
Optional("non_local_items"): [And(str, len)],
|
||||||
Optional("link_replacement"): Or(None, bool),
|
Optional("link_replacement"): Or(None, bool),
|
||||||
Optional("skip_if_solo"): Or(None, bool),
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1474,10 +1473,8 @@ class ItemLinks(OptionList):
|
|||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
link["name"] = link["name"].strip()[:16].strip()
|
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
|
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||||
f"You have more than one link named '{link['name']}'.")
|
|
||||||
existing_links.add(link["name"])
|
existing_links.add(link["name"])
|
||||||
|
|
||||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||||
@@ -1755,10 +1752,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
res = template.render(
|
res = template.render(
|
||||||
option_groups=option_groups,
|
option_groups=option_groups,
|
||||||
__version__=__version__,
|
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||||
game=game_name,
|
|
||||||
world_version=world.world_version.as_simple_string(),
|
|
||||||
yaml_dump=yaml_dump_scalar,
|
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
cleandoc=cleandoc,
|
cleandoc=cleandoc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import settings
|
from settings import Settings
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = settings.get_settings().sni_options.sni_path
|
sni_path = Settings.sni_options.sni_path
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = settings.get_settings().sni_options.snes_rom_start
|
auto_start = Settings.sni_options.snes_rom_start
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
59
Utils.py
59
Utils.py
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
|
||||||
import json
|
import json
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
@@ -36,7 +35,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
return Version(*(int(piece) for piece in version.split(".")))
|
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||||
|
|
||||||
|
|
||||||
class Version(typing.NamedTuple):
|
class Version(typing.NamedTuple):
|
||||||
@@ -323,13 +322,11 @@ def get_options() -> Settings:
|
|||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
def persistent_store(category: str, key: str, value: typing.Any):
|
||||||
|
path = user_path("_persistent_storage.yaml")
|
||||||
storage = persistent_load()
|
storage = persistent_load()
|
||||||
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
|
|
||||||
return # no changes necessary
|
|
||||||
category_dict = storage.setdefault(category, {})
|
category_dict = storage.setdefault(category, {})
|
||||||
category_dict[key] = value
|
category_dict[key] = value
|
||||||
path = user_path("_persistent_storage.yaml")
|
|
||||||
with open(path, "wt") as f:
|
with open(path, "wt") as f:
|
||||||
f.write(dump(storage, Dumper=Dumper))
|
f.write(dump(storage, Dumper=Dumper))
|
||||||
|
|
||||||
@@ -478,7 +475,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
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.PlandoItem, self.options_module.PlandoText)):
|
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")
|
||||||
@@ -721,22 +718,13 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
|
|
||||||
|
|
||||||
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
||||||
"""
|
|
||||||
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
|
|
||||||
arguments with it.
|
|
||||||
|
|
||||||
:param text: The response text from `get_intended_text`.
|
|
||||||
:param command: The command to which the input text should be added. Must contain the prefix used by the command
|
|
||||||
(`!` or `/`).
|
|
||||||
:return: The command with the suggested input text appended, or None if no suggestion was found.
|
|
||||||
"""
|
|
||||||
if "did you mean " in text:
|
if "did you mean " in text:
|
||||||
for question in ("Didn't find something that closely matches",
|
for question in ("Didn't find something that closely matches",
|
||||||
"Too many close matches"):
|
"Too many close matches"):
|
||||||
if text.startswith(question):
|
if text.startswith(question):
|
||||||
name = get_text_between(text, "did you mean '",
|
name = get_text_between(text, "did you mean '",
|
||||||
"'? (")
|
"'? (")
|
||||||
return f"{command} {name}"
|
return f"!{command} {name}"
|
||||||
elif text.startswith("Missing: "):
|
elif text.startswith("Missing: "):
|
||||||
return text.replace("Missing: ", "!hint_location ")
|
return text.replace("Missing: ", "!hint_location ")
|
||||||
return None
|
return None
|
||||||
@@ -1139,40 +1127,3 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
|||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return False
|
return False
|
||||||
return isinstance(obj, typing.Iterable)
|
return isinstance(obj, typing.Iterable)
|
||||||
|
|
||||||
|
|
||||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
|
||||||
"""
|
|
||||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
|
||||||
NOTE: use this with caution because killed threads will not properly clean up.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _adjust_thread_count(self):
|
|
||||||
# see upstream ThreadPoolExecutor for details
|
|
||||||
import threading
|
|
||||||
import weakref
|
|
||||||
from concurrent.futures.thread import _worker
|
|
||||||
|
|
||||||
if self._idle_semaphore.acquire(timeout=0):
|
|
||||||
return
|
|
||||||
|
|
||||||
def weakref_cb(_, q=self._work_queue):
|
|
||||||
q.put(None)
|
|
||||||
|
|
||||||
num_threads = len(self._threads)
|
|
||||||
if num_threads < self._max_workers:
|
|
||||||
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
|
||||||
t = threading.Thread(
|
|
||||||
name=thread_name,
|
|
||||||
target=_worker,
|
|
||||||
args=(
|
|
||||||
weakref.ref(self, weakref_cb),
|
|
||||||
self._work_queue,
|
|
||||||
self._initializer,
|
|
||||||
self._initargs,
|
|
||||||
),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t.start()
|
|
||||||
self._threads.add(t)
|
|
||||||
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
|
||||||
|
|||||||
@@ -109,13 +109,6 @@ if __name__ == "__main__":
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.warning("Could not update LttP sprites.")
|
logging.warning("Could not update LttP sprites.")
|
||||||
app = get_app()
|
app = get_app()
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
# Update to only valid WebHost worlds
|
|
||||||
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
|
|
||||||
if not hasattr(world.web, "tutorials")}
|
|
||||||
if invalid_worlds:
|
|
||||||
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
|
|
||||||
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
|
|
||||||
create_options_files()
|
create_options_files()
|
||||||
copy_tutorials_files_to_static()
|
copy_tutorials_files_to_static()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import typing
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -62,21 +61,20 @@ cache = Cache()
|
|||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
def to_python(value: str) -> uuid.UUID:
|
def to_python(value):
|
||||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
|
|
||||||
def to_url(value: uuid.UUID) -> str:
|
def to_url(value):
|
||||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value: str) -> uuid.UUID:
|
def to_python(self, value):
|
||||||
return to_python(value)
|
return to_python(value)
|
||||||
|
|
||||||
def to_url(self, value: typing.Any) -> str:
|
def to_url(self, value):
|
||||||
assert isinstance(value, uuid.UUID)
|
|
||||||
return to_url(value)
|
return to_url(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +84,7 @@ app.jinja_env.filters["suuid"] = to_url
|
|||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register() -> None:
|
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."""
|
||||||
import importlib
|
import importlib
|
||||||
|
|||||||
@@ -11,53 +11,6 @@ from WebHostLib.models import Room
|
|||||||
from WebHostLib.tracker import TrackerData
|
from WebHostLib.tracker import TrackerData
|
||||||
|
|
||||||
|
|
||||||
class PlayerAlias(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
alias: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerItemsReceived(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
items: list[NetworkItem]
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerChecksDone(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
locations: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class TeamTotalChecks(TypedDict):
|
|
||||||
team: int
|
|
||||||
checks_done: int
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerHints(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
hints: list[Hint]
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerTimer(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
time: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerStatus(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
status: ClientStatus
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerLocationsTotal(TypedDict):
|
|
||||||
team: int
|
|
||||||
player: int
|
|
||||||
total_locations: int
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route("/tracker/<suuid:tracker>")
|
@api_endpoints.route("/tracker/<suuid:tracker>")
|
||||||
@cache.memoize(timeout=60)
|
@cache.memoize(timeout=60)
|
||||||
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||||
@@ -76,77 +29,122 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
|||||||
|
|
||||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
player_aliases: list[PlayerAlias] = []
|
class PlayerAlias(TypedDict):
|
||||||
|
player: int
|
||||||
|
name: str | None
|
||||||
|
|
||||||
|
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
|
||||||
"""Slot aliases of all players."""
|
"""Slot aliases of all players."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
team_player_aliases: list[PlayerAlias] = []
|
||||||
|
team_aliases = {"team": team, "players": team_player_aliases}
|
||||||
|
player_aliases.append(team_aliases)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_aliases.append({"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||||
|
|
||||||
player_items_received: list[PlayerItemsReceived] = []
|
class PlayerItemsReceived(TypedDict):
|
||||||
|
player: int
|
||||||
|
items: list[NetworkItem]
|
||||||
|
|
||||||
|
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
|
||||||
"""Items received by each player."""
|
"""Items received by each player."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
player_received_items: list[PlayerItemsReceived] = []
|
||||||
|
team_items_received = {"team": team, "players": player_received_items}
|
||||||
|
player_items_received.append(team_items_received)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_items_received.append(
|
player_received_items.append(
|
||||||
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
|
{"player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||||
|
|
||||||
player_checks_done: list[PlayerChecksDone] = []
|
class PlayerChecksDone(TypedDict):
|
||||||
|
player: int
|
||||||
|
locations: list[int]
|
||||||
|
|
||||||
|
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
|
||||||
"""ID of all locations checked by each player."""
|
"""ID of all locations checked by each player."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
per_player_checks: list[PlayerChecksDone] = []
|
||||||
|
team_checks_done = {"team": team, "players": per_player_checks}
|
||||||
|
player_checks_done.append(team_checks_done)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_checks_done.append(
|
per_player_checks.append(
|
||||||
{"team": team, "player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||||
|
|
||||||
total_checks_done: list[TeamTotalChecks] = [
|
total_checks_done: list[dict[str, int]] = [
|
||||||
{"team": team, "checks_done": checks_done}
|
{"team": team, "checks_done": checks_done}
|
||||||
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
|
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
|
||||||
]
|
]
|
||||||
"""Total number of locations checked for the entire multiworld per team."""
|
"""Total number of locations checked for the entire multiworld per team."""
|
||||||
|
|
||||||
hints: list[PlayerHints] = []
|
class PlayerHints(TypedDict):
|
||||||
|
player: int
|
||||||
|
hints: list[Hint]
|
||||||
|
|
||||||
|
hints: list[dict[str, int | list[PlayerHints]]] = []
|
||||||
"""Hints that all players have used or received."""
|
"""Hints that all players have used or received."""
|
||||||
for team, players in tracker_data.get_all_slots().items():
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
per_player_hints: list[PlayerHints] = []
|
||||||
|
team_hints = {"team": team, "players": per_player_hints}
|
||||||
|
hints.append(team_hints)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
||||||
hints.append({"team": team, "player": player, "hints": player_hints})
|
per_player_hints.append({"player": player, "hints": player_hints})
|
||||||
slot_info = tracker_data.get_slot_info(player)
|
slot_info = tracker_data.get_slot_info(team, player)
|
||||||
# this assumes groups are always after players
|
# this assumes groups are always after players
|
||||||
if slot_info.type != SlotType.group:
|
if slot_info.type != SlotType.group:
|
||||||
continue
|
continue
|
||||||
for member in slot_info.group_members:
|
for member in slot_info.group_members:
|
||||||
hints[member - 1]["hints"] += player_hints
|
team_hints[member]["hints"] += player_hints
|
||||||
|
|
||||||
activity_timers: list[PlayerTimer] = []
|
class PlayerTimer(TypedDict):
|
||||||
|
player: int
|
||||||
|
time: datetime | None
|
||||||
|
|
||||||
|
activity_timers: list[dict[str, int | list[PlayerTimer]]] = []
|
||||||
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
|
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
player_timers: list[PlayerTimer] = []
|
||||||
|
team_timers = {"team": team, "players": player_timers}
|
||||||
|
activity_timers.append(team_timers)
|
||||||
for player in players:
|
for player in players:
|
||||||
activity_timers.append({"team": team, "player": player, "time": None})
|
player_timers.append({"player": player, "time": None})
|
||||||
|
|
||||||
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
|
client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ())
|
||||||
for entry in activity_timers:
|
for (team, player), timestamp in client_activity_timers:
|
||||||
if entry["team"] == team and entry["player"] == player:
|
# use index since we can rely on order
|
||||||
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
# FIX: key is "players" (not "player_timers")
|
||||||
break
|
activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
|
||||||
connection_timers: list[PlayerTimer] = []
|
|
||||||
|
connection_timers: list[dict[str, int | list[PlayerTimer]]] = []
|
||||||
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
|
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
player_timers: list[PlayerTimer] = []
|
||||||
|
team_connection_timers = {"team": team, "players": player_timers}
|
||||||
|
connection_timers.append(team_connection_timers)
|
||||||
for player in players:
|
for player in players:
|
||||||
connection_timers.append({"team": team, "player": player, "time": None})
|
player_timers.append({"player": player, "time": None})
|
||||||
|
|
||||||
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
|
client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get(
|
||||||
# find the matching entry
|
"client_connection_timers", ())
|
||||||
for entry in connection_timers:
|
for (team, player), timestamp in client_connection_timers:
|
||||||
if entry["team"] == team and entry["player"] == player:
|
connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
|
||||||
break
|
|
||||||
|
|
||||||
player_status: list[PlayerStatus] = []
|
class PlayerStatus(TypedDict):
|
||||||
|
player: int
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
|
player_status: list[dict[str, int | list[PlayerStatus]]] = []
|
||||||
"""The current client status for each player."""
|
"""The current client status for each player."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
player_statuses: list[PlayerStatus] = []
|
||||||
|
team_status = {"team": team, "players": player_statuses}
|
||||||
|
player_status.append(team_status)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_status.append({"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
**get_static_tracker_data(room),
|
||||||
"aliases": player_aliases,
|
"aliases": player_aliases,
|
||||||
"player_items_received": player_items_received,
|
"player_items_received": player_items_received,
|
||||||
"player_checks_done": player_checks_done,
|
"player_checks_done": player_checks_done,
|
||||||
@@ -155,87 +153,80 @@ def tracker_data(tracker: UUID) -> dict[str, Any]:
|
|||||||
"activity_timers": activity_timers,
|
"activity_timers": activity_timers,
|
||||||
"connection_timers": connection_timers,
|
"connection_timers": connection_timers,
|
||||||
"player_status": player_status,
|
"player_status": player_status,
|
||||||
|
"datapackage": tracker_data._multidata["datapackage"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@cache.memoize()
|
||||||
class PlayerGroups(TypedDict):
|
def get_static_tracker_data(room: Room) -> dict[str, Any]:
|
||||||
slot: int
|
|
||||||
name: str
|
|
||||||
members: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerSlotData(TypedDict):
|
|
||||||
player: int
|
|
||||||
slot_data: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@api_endpoints.route("/static_tracker/<suuid:tracker>")
|
|
||||||
@cache.memoize(timeout=300)
|
|
||||||
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
|
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
|
||||||
|
|
||||||
:param tracker: UUID of current session tracker.
|
|
||||||
|
|
||||||
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
|
||||||
"""
|
"""
|
||||||
room: Room | None = Room.get(tracker=tracker)
|
|
||||||
if not room:
|
|
||||||
abort(404)
|
|
||||||
tracker_data = TrackerData(room)
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
groups: list[PlayerGroups] = []
|
class PlayerGroups(TypedDict):
|
||||||
|
slot: int
|
||||||
|
name: str
|
||||||
|
members: list[int]
|
||||||
|
|
||||||
|
groups: list[dict[str, int | list[PlayerGroups]]] = []
|
||||||
"""The Slot ID of groups and the IDs of the group's members."""
|
"""The Slot ID of groups and the IDs of the group's members."""
|
||||||
for team, players in tracker_data.get_all_slots().items():
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
groups_in_team: list[PlayerGroups] = []
|
||||||
|
team_groups = {"team": team, "groups": groups_in_team}
|
||||||
|
groups.append(team_groups)
|
||||||
for player in players:
|
for player in players:
|
||||||
slot_info = tracker_data.get_slot_info(player)
|
slot_info = tracker_data.get_slot_info(team, player)
|
||||||
if slot_info.type != SlotType.group or not slot_info.group_members:
|
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||||
continue
|
continue
|
||||||
groups.append(
|
groups_in_team.append(
|
||||||
{
|
{
|
||||||
"slot": player,
|
"slot": player,
|
||||||
"name": slot_info.name,
|
"name": slot_info.name,
|
||||||
"members": list(slot_info.group_members),
|
"members": list(slot_info.group_members),
|
||||||
})
|
})
|
||||||
break
|
class PlayerName(TypedDict):
|
||||||
|
player: int
|
||||||
|
name: str
|
||||||
|
|
||||||
player_locations_total: list[PlayerLocationsTotal] = []
|
player_names: list[dict[str, str | list[PlayerName]]] = []
|
||||||
|
"""Slot names of all players."""
|
||||||
for team, players in all_players.items():
|
for team, players in all_players.items():
|
||||||
|
per_team_player_names: list[PlayerName] = []
|
||||||
|
team_names = {"team": team, "players": per_team_player_names}
|
||||||
|
player_names.append(team_names)
|
||||||
for player in players:
|
for player in players:
|
||||||
player_locations_total.append(
|
per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
|
||||||
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
|
||||||
|
class PlayerGame(TypedDict):
|
||||||
|
player: int
|
||||||
|
game: str
|
||||||
|
|
||||||
|
games: list[dict[str, int | list[PlayerGame]]] = []
|
||||||
|
"""The game each player is playing."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_games: list[PlayerGame] = []
|
||||||
|
team_games = {"team": team, "players": player_games}
|
||||||
|
games.append(team_games)
|
||||||
|
for player in players:
|
||||||
|
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
|
||||||
|
|
||||||
|
class PlayerSlotData(TypedDict):
|
||||||
|
player: int
|
||||||
|
slot_data: dict[str, Any]
|
||||||
|
|
||||||
|
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
|
||||||
|
"""Slot data for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_slot_data: list[PlayerSlotData] = []
|
||||||
|
team_slot_data = {"team": team, "players": player_slot_data}
|
||||||
|
slot_data.append(team_slot_data)
|
||||||
|
for player in players:
|
||||||
|
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"datapackage": tracker_data._multidata["datapackage"],
|
"slot_data": slot_data,
|
||||||
"player_locations_total": player_locations_total,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# It should be exceedingly rare that slot data is needed, so it's separated out.
|
|
||||||
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
|
|
||||||
@cache.memoize(timeout=300)
|
|
||||||
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
|
|
||||||
"""
|
|
||||||
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
|
|
||||||
|
|
||||||
:param tracker: UUID of current session tracker.
|
|
||||||
|
|
||||||
:return: Slot data for all players in the room. Typing completely arbitrary per game.
|
|
||||||
"""
|
|
||||||
room: Room | None = Room.get(tracker=tracker)
|
|
||||||
if not room:
|
|
||||||
abort(404)
|
|
||||||
tracker_data = TrackerData(room)
|
|
||||||
|
|
||||||
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
|
||||||
|
|
||||||
slot_data: list[PlayerSlotData] = []
|
|
||||||
"""Slot data for each player."""
|
|
||||||
for team, players in all_players.items():
|
|
||||||
for player in players:
|
|
||||||
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
|
|
||||||
break
|
|
||||||
|
|
||||||
return slot_data
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
|||||||
_stop_event = Event()
|
_stop_event = Event()
|
||||||
|
|
||||||
|
|
||||||
def stop() -> None:
|
def stop():
|
||||||
"""Stops previously launched threads"""
|
"""Stops previously launched threads"""
|
||||||
global _stop_event
|
global _stop_event
|
||||||
stop_event = _stop_event
|
stop_event = _stop_event
|
||||||
@@ -36,39 +36,25 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def _mp_gen_game(
|
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||||
gen_options: dict,
|
|
||||||
meta: dict[str, Any] | None = None,
|
|
||||||
owner=None,
|
|
||||||
sid=None,
|
|
||||||
timeout: int|None = None,
|
|
||||||
) -> PrimaryKey | None:
|
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
setproctitle(f"Generator ({sid})")
|
setproctitle(f"Generator ({sid})")
|
||||||
try:
|
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
setproctitle(f"Generator (idle)")
|
||||||
finally:
|
return res
|
||||||
setproctitle(f"Generator (idle)")
|
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
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(
|
pool.apply_async(_mp_gen_game, (options,),
|
||||||
_mp_gen_game,
|
{"meta": meta,
|
||||||
(options,),
|
"sid": generation.id,
|
||||||
{
|
"owner": generation.owner},
|
||||||
"meta": meta,
|
handle_generation_success, handle_generation_failure)
|
||||||
"sid": generation.id,
|
|
||||||
"owner": generation.owner,
|
|
||||||
"timeout": timeout,
|
|
||||||
},
|
|
||||||
handle_generation_success,
|
|
||||||
handle_generation_failure,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
generation.state = STATE_ERROR
|
generation.state = STATE_ERROR
|
||||||
commit()
|
commit()
|
||||||
@@ -149,7 +135,6 @@ def autogen(config: dict):
|
|||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||||
job_time = config["JOB_TIME"]
|
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -160,7 +145,7 @@ def autogen(config: dict):
|
|||||||
if sid:
|
if sid:
|
||||||
generation.delete()
|
generation.delete()
|
||||||
else:
|
else:
|
||||||
launch_generator(generator_pool, generation, timeout=job_time)
|
launch_generator(generator_pool, generation)
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||||
@@ -172,7 +157,7 @@ def autogen(config: dict):
|
|||||||
generation for generation in Generation
|
generation for generation in Generation
|
||||||
if generation.state == STATE_QUEUED).for_update()
|
if generation.state == STATE_QUEUED).for_update()
|
||||||
for generation in to_start:
|
for generation in to_start:
|
||||||
launch_generator(generator_pool, generation, timeout=job_time)
|
launch_generator(generator_pool, generation)
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autogen reports as already running, not starting another.")
|
logging.info("Autogen reports as already running, not starting another.")
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ from pony.orm import commit, db_session, select
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
from MultiServer import (
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||||
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
|
|
||||||
server_per_message_deflate_factory,
|
|
||||||
)
|
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
from .locker import Locker
|
from .locker import Locker
|
||||||
from .models import Command, GameDataPackage, Room, db
|
from .models import Command, GameDataPackage, Room, db
|
||||||
@@ -100,7 +97,6 @@ class WebHostContext(Context):
|
|||||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||||
command.delete()
|
command.delete()
|
||||||
commit()
|
commit()
|
||||||
del commands
|
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
@@ -150,13 +146,13 @@ class WebHostContext(Context):
|
|||||||
self.location_name_groups = static_location_name_groups
|
self.location_name_groups = static_location_name_groups
|
||||||
return self._load(multidata, game_data_packages, True)
|
return self._load(multidata, game_data_packages, True)
|
||||||
|
|
||||||
|
@db_session
|
||||||
def init_save(self, enabled: bool = True):
|
def init_save(self, enabled: bool = True):
|
||||||
self.saving = enabled
|
self.saving = enabled
|
||||||
if self.saving:
|
if self.saving:
|
||||||
with db_session:
|
savegame_data = Room.get(id=self.room_id).multisave
|
||||||
savegame_data = Room.get(id=self.room_id).multisave
|
if savegame_data:
|
||||||
if savegame_data:
|
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
|
||||||
self._start_async_saving(atexit_save=False)
|
self._start_async_saving(atexit_save=False)
|
||||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||||
|
|
||||||
@@ -286,12 +282,8 @@ 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),
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||||
ctx.host,
|
|
||||||
ctx.port,
|
|
||||||
ssl=get_ssl_context(),
|
|
||||||
extensions=[server_per_message_deflate_factory],
|
|
||||||
)
|
|
||||||
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(
|
||||||
@@ -312,7 +304,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=ctx.room_id)
|
room = Room.get(id=ctx.room_id)
|
||||||
room.last_port = port
|
room.last_port = port
|
||||||
del room
|
|
||||||
else:
|
else:
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -331,7 +322,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_port = -1
|
room.last_port = -1
|
||||||
del room
|
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
@@ -343,12 +333,11 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||||
with db_session:
|
with (db_session):
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_activity = datetime.datetime.utcnow() - \
|
room.last_activity = datetime.datetime.utcnow() - \
|
||||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
del room
|
|
||||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||||
finally:
|
finally:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
|
|||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
from Utils import __version__, restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
@@ -33,7 +33,6 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
|
|||||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||||
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
|
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||||
"server_password": str(options_source.get("server_password", None)),
|
"server_password": str(options_source.get("server_password", None)),
|
||||||
}
|
}
|
||||||
@@ -73,10 +72,6 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def format_exception(e: BaseException) -> str:
|
|
||||||
return f"{e.__class__.__name__}: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
@@ -97,9 +92,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
except PicklingError as e:
|
except PicklingError as e:
|
||||||
from .autolauncher import handle_generation_failure
|
from .autolauncher import handle_generation_failure
|
||||||
handle_generation_failure(e)
|
handle_generation_failure(e)
|
||||||
meta["error"] = format_exception(e)
|
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
|
||||||
details = json.dumps(meta, indent=4).strip()
|
|
||||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
|
|
||||||
@@ -107,18 +100,16 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||||
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
meta=meta, owner=session["_id"].int)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
from .autolauncher import handle_generation_failure
|
from .autolauncher import handle_generation_failure
|
||||||
handle_generation_failure(e)
|
handle_generation_failure(e)
|
||||||
meta["error"] = format_exception(e)
|
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
||||||
details = json.dumps(meta, indent=4).strip()
|
|
||||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
|
||||||
|
|
||||||
return redirect(url_for("view_seed", seed=seed_id))
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
||||||
if meta is None:
|
if meta is None:
|
||||||
meta = {}
|
meta = {}
|
||||||
|
|
||||||
@@ -137,7 +128,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
args = mystery_argparse()
|
||||||
args.multi = playercount
|
args.multi = playercount
|
||||||
args.seed = seed
|
args.seed = seed
|
||||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
@@ -172,12 +163,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
|
||||||
thread = thread_pool.submit(task)
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return thread.result(timeout)
|
return thread.result(app.config["JOB_TIME"])
|
||||||
except concurrent.futures.TimeoutError as e:
|
except concurrent.futures.TimeoutError as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -185,14 +175,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = ("Allowed time for Generation exceeded, " +
|
meta["error"] = (
|
||||||
"please consider generating locally instead. " +
|
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||||
format_exception(e))
|
e.__class__.__name__ + ": " + str(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
except (KeyboardInterrupt, SystemExit):
|
|
||||||
# don't update db, retry next time
|
|
||||||
raise
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -200,15 +187,10 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = format_exception(e)
|
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
# free resources claimed by thread pool, if possible
|
|
||||||
# NOTE: Timeout depends on the process being killed at some point
|
|
||||||
# since we can't actually cancel a running gen at the moment.
|
|
||||||
thread_pool.shutdown(wait=False, cancel_futures=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/wait/<suuid:seed>')
|
@app.route('/wait/<suuid:seed>')
|
||||||
@@ -222,9 +204,7 @@ def wait_seed(seed: UUID):
|
|||||||
if not generation:
|
if not generation:
|
||||||
return "Generation not found."
|
return "Generation not found."
|
||||||
elif generation.state == STATE_ERROR:
|
elif generation.state == STATE_ERROR:
|
||||||
meta = json.loads(generation.meta)
|
return render_template("seedError.html", seed_error=generation.meta)
|
||||||
details = json.dumps(meta, indent=4).strip()
|
|
||||||
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
|
||||||
return render_template("waitSeed.html", seed_id=seed_id)
|
return render_template("waitSeed.html", seed_id=seed_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import re
|
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
import mistune
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ImgUrlRewriteInlineParser",
|
|
||||||
'render_markdown',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ImgUrlRewriteInlineParser(mistune.InlineParser):
|
|
||||||
relative_url_base: str
|
|
||||||
|
|
||||||
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
|
|
||||||
super().__init__(hard_wrap)
|
|
||||||
self.relative_url_base = relative_url_base
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_game_name_by_folder_name(name: str) -> str | None:
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
|
|
||||||
for world_name, world_type in AutoWorldRegister.world_types.items():
|
|
||||||
if world_type.__module__ == f"worlds.{name}":
|
|
||||||
return world_name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
|
|
||||||
res = super().parse_link(m, state)
|
|
||||||
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
|
|
||||||
image_token = state.tokens[-1]
|
|
||||||
url: str = image_token["attrs"]["url"]
|
|
||||||
if not url.startswith("/") and not "://" in url:
|
|
||||||
# replace relative URL to another world's doc folder with the webhost folder layout
|
|
||||||
if url.startswith("../../") and "/docs/" in self.relative_url_base:
|
|
||||||
parts = url.split("/", 4)
|
|
||||||
if parts[2] != ".." and parts[3] == "docs":
|
|
||||||
game_name = self._find_game_name_by_folder_name(parts[2])
|
|
||||||
if game_name is not None:
|
|
||||||
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
|
|
||||||
# change relative URL to point to deployment folder
|
|
||||||
url = f"{self.relative_url_base}/{url}"
|
|
||||||
image_token['attrs']['url'] = url
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
def render_markdown(path: str, img_url_base: str | None = None) -> str:
|
|
||||||
markdown = mistune.create_markdown(
|
|
||||||
escape=False,
|
|
||||||
plugins=[
|
|
||||||
"strikethrough",
|
|
||||||
"footnotes",
|
|
||||||
"table",
|
|
||||||
"speedup",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
heading_id_count: Counter[str] = Counter()
|
|
||||||
|
|
||||||
def heading_id(text: str) -> str:
|
|
||||||
nonlocal heading_id_count
|
|
||||||
|
|
||||||
# there is no good way to do this without regex
|
|
||||||
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
|
||||||
n = heading_id_count[s]
|
|
||||||
heading_id_count[s] += 1
|
|
||||||
if n > 0:
|
|
||||||
s += f"-{n}"
|
|
||||||
return s
|
|
||||||
|
|
||||||
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
|
||||||
for tok in state.tokens:
|
|
||||||
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
|
||||||
text = tok["text"]
|
|
||||||
assert isinstance(text, str)
|
|
||||||
unique_id = heading_id(text)
|
|
||||||
tok["attrs"]["id"] = unique_id
|
|
||||||
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
|
||||||
|
|
||||||
markdown.before_render_hooks.append(id_hook)
|
|
||||||
if img_url_base:
|
|
||||||
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
|
|
||||||
|
|
||||||
with open(path, encoding="utf-8-sig") as f:
|
|
||||||
document = f.read()
|
|
||||||
html = markdown(document)
|
|
||||||
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
|
|
||||||
return html
|
|
||||||
@@ -9,7 +9,6 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister, World
|
from worlds.AutoWorld import AutoWorldRegister, World
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .markdown import render_markdown
|
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
from Utils import title_sorted
|
from Utils import title_sorted
|
||||||
|
|
||||||
@@ -28,6 +27,49 @@ def get_visible_worlds() -> dict[str, type(World)]:
|
|||||||
return worlds
|
return worlds
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(path: str) -> str:
|
||||||
|
import mistune
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
markdown = mistune.create_markdown(
|
||||||
|
escape=False,
|
||||||
|
plugins=[
|
||||||
|
"strikethrough",
|
||||||
|
"footnotes",
|
||||||
|
"table",
|
||||||
|
"speedup",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
heading_id_count: Counter[str] = Counter()
|
||||||
|
|
||||||
|
def heading_id(text: str) -> str:
|
||||||
|
nonlocal heading_id_count
|
||||||
|
import re # there is no good way to do this without regex
|
||||||
|
|
||||||
|
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||||
|
n = heading_id_count[s]
|
||||||
|
heading_id_count[s] += 1
|
||||||
|
if n > 0:
|
||||||
|
s += f"-{n}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||||
|
for tok in state.tokens:
|
||||||
|
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||||
|
text = tok["text"]
|
||||||
|
assert isinstance(text, str)
|
||||||
|
unique_id = heading_id(text)
|
||||||
|
tok["attrs"]["id"] = unique_id
|
||||||
|
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||||
|
|
||||||
|
markdown.before_render_hooks.append(id_hook)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
|
document = f.read()
|
||||||
|
return markdown(document)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||||
def page_not_found(err):
|
def page_not_found(err):
|
||||||
@@ -49,9 +91,10 @@ def game_info(game, lang):
|
|||||||
theme = get_world_theme(game)
|
theme = get_world_theme(game)
|
||||||
secure_game_name = secure_filename(game)
|
secure_game_name = secure_filename(game)
|
||||||
lang = secure_filename(lang)
|
lang = secure_filename(lang)
|
||||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
document = render_markdown(os.path.join(
|
||||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
app.static_folder, "generated", "docs",
|
||||||
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
|
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||||
|
))
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title=f"{game} Guide",
|
title=f"{game} Guide",
|
||||||
@@ -76,9 +119,10 @@ def tutorial(game: str, file: str):
|
|||||||
theme = get_world_theme(game)
|
theme = get_world_theme(game)
|
||||||
secure_game_name = secure_filename(game)
|
secure_game_name = secure_filename(game)
|
||||||
file = secure_filename(file)
|
file = secure_filename(file)
|
||||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
document = render_markdown(os.path.join(
|
||||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
app.static_folder, "generated", "docs",
|
||||||
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
|
secure_game_name, file+".md"
|
||||||
|
))
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title=f"{game} Guide",
|
title=f"{game} Guide",
|
||||||
@@ -216,10 +260,7 @@ def host_room(room: UUID):
|
|||||||
# indicate that the page should reload to get the assigned port
|
# indicate that the page should reload to get the assigned port
|
||||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||||
|
with db_session:
|
||||||
if now - room.last_activity > datetime.timedelta(minutes=1):
|
|
||||||
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
|
||||||
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||||
@@ -227,9 +268,9 @@ def host_room(room: UUID):
|
|||||||
or "Discordbot" in request.user_agent.string
|
or "Discordbot" in request.user_agent.string
|
||||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||||
|
|
||||||
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
|
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
||||||
if max_size == 0:
|
if max_size == 0:
|
||||||
return "…", 0
|
return "…"
|
||||||
try:
|
try:
|
||||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||||
raw_size = 0
|
raw_size = 0
|
||||||
@@ -240,9 +281,9 @@ def host_room(room: UUID):
|
|||||||
break
|
break
|
||||||
raw_size += len(block)
|
raw_size += len(block)
|
||||||
fragments.append(block.decode("utf-8"))
|
fragments.append(block.decode("utf-8"))
|
||||||
return "".join(fragments), raw_size
|
return "".join(fragments)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "", 0
|
return ""
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
|||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||||
|
|
||||||
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||||
'raw_enable': False,
|
'raw_enable': False,
|
||||||
'file_insertion_enabled': False,
|
'file_insertion_enabled': False,
|
||||||
'output_encoding': 'unicode'
|
'output_encoding': 'unicode'
|
||||||
@@ -231,7 +231,7 @@ def generate_yaml(game: str):
|
|||||||
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]] = {}
|
||||||
if val and val != "0":
|
if val != "0":
|
||||||
options[key_parts[0]][key_parts[1]] = int(val)
|
options[key_parts[0]][key_parts[1]] = int(val)
|
||||||
del options[key]
|
del options[key]
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ pony>=0.7.19; python_version <= '3.12'
|
|||||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||||
waitress>=3.0.2
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
Flask-Compress>=1.17
|
||||||
Flask-Limiter>=3.12
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.6.3
|
bokeh>=3.6.3
|
||||||
markupsafe>=3.0.2
|
markupsafe>=3.0.2
|
||||||
setproctitle>=1.3.5
|
setproctitle>=1.3.5
|
||||||
mistune>=3.1.3
|
mistune>=3.1.3
|
||||||
docutils>=0.22.2
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
|
|||||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||||
comfortable exploiting certain glitches in the game.
|
comfortable exploiting certain glitches in the game.
|
||||||
|
|
||||||
## I want to develop a game implementation for Archipelago. How do I do that?
|
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||||
|
|
||||||
The best way to get started is to take a look at our code on GitHub:
|
The best way to get started is to take a look at our code on GitHub:
|
||||||
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||||
@@ -77,5 +77,4 @@ There, you will find examples of games in the `worlds` folder:
|
|||||||
You may also find developer documentation in the `docs` folder:
|
You may also find developer documentation in the `docs` folder:
|
||||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||||
|
|
||||||
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
|
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
||||||
channel on our Discord.
|
|
||||||
|
|||||||
@@ -72,13 +72,3 @@ code{
|
|||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
code.grassy {
|
|
||||||
background-color: #b5e9a4;
|
|
||||||
border: 1px solid #2a6c2f;
|
|
||||||
white-space: preserve;
|
|
||||||
text-align: left;
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,3 @@
|
|||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2, h4 {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.finding_player == player %}
|
{% if hint.finding_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||||
{% elif get_slot_info(hint.finding_player).type == 2 %}
|
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.receiving_player == player %}
|
{% if hint.receiving_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||||
{% elif get_slot_info(hint.receiving_player).type == 2 %}
|
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
Open Log File...
|
Open Log File...
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% set log, log_len = get_log() -%}
|
{% set log = get_log() -%}
|
||||||
|
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
||||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||||
<script>
|
<script>
|
||||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||||
|
|||||||
@@ -45,15 +45,15 @@
|
|||||||
{%- set current_sphere = loop.index %}
|
{%- set current_sphere = loop.index %}
|
||||||
{%- for player, sphere_location_ids in sphere.items() %}
|
{%- for player, sphere_location_ids in sphere.items() %}
|
||||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||||
{%- set finder_game = tracker_data.get_player_game(player) %}
|
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
||||||
{%- set player_location_data = tracker_data.get_player_locations(player) %}
|
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
||||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||||
<tr>
|
<tr>
|
||||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
||||||
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
|
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
||||||
<td>{{ current_sphere }}</td>
|
<td>{{ current_sphere }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(player) }}</td>
|
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(receiver) }}</td>
|
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
||||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
||||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
||||||
<td>{{ finder_game }}</td>
|
<td>{{ finder_game }}</td>
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
-%}
|
-%}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(hint.finding_player).type == 2 %}
|
{% if get_slot_info(team, hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(hint.receiving_player).type == 2 %}
|
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||||
|
|||||||
@@ -4,20 +4,16 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
<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 %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% include 'header/oceanIslandHeader.html' %}
|
{% include 'header/oceanIslandHeader.html' %}
|
||||||
<div id="wait-seed-wrapper" class="grass-island">
|
<div id="wait-seed-wrapper" class="grass-island">
|
||||||
<div id="wait-seed">
|
<div id="wait-seed">
|
||||||
<h1>Generation Failed</h1>
|
<h1>Generation failed</h1>
|
||||||
<h2>Please try again!</h2>
|
<h2>please retry</h2>
|
||||||
<p>{{ seed_error }}</p>
|
{{ seed_error }}
|
||||||
<h4>More details:</h4>
|
|
||||||
<p>
|
|
||||||
<code class="grassy">{{ details }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -31,9 +31,6 @@
|
|||||||
{% include 'header/oceanHeader.html' %}
|
{% include 'header/oceanHeader.html' %}
|
||||||
<div id="games" class="markdown">
|
<div id="games" class="markdown">
|
||||||
<h1>Currently Supported Games</h1>
|
<h1>Currently Supported Games</h1>
|
||||||
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
|
|
||||||
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
|
|
||||||
custom worlds</a> section of the setup guide.</p>
|
|
||||||
<div class="js-only">
|
<div class="js-only">
|
||||||
<label for="game-search">Search for your game below!</label><br />
|
<label for="game-search">Search for your game below!</label><br />
|
||||||
<div class="page-controls">
|
<div class="page-controls">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .models import GameDataPackage, Room
|
|||||||
# Multisave is currently updated, at most, every minute.
|
# Multisave is currently updated, at most, every minute.
|
||||||
TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60
|
TRACKER_CACHE_TIMEOUT_IN_SECONDS = 60
|
||||||
|
|
||||||
|
_multidata_cache = {}
|
||||||
_multiworld_trackers: Dict[str, Callable] = {}
|
_multiworld_trackers: Dict[str, Callable] = {}
|
||||||
_player_trackers: Dict[str, Callable] = {}
|
_player_trackers: Dict[str, Callable] = {}
|
||||||
|
|
||||||
@@ -84,27 +85,27 @@ class TrackerData:
|
|||||||
"""Retrieves the seed name."""
|
"""Retrieves the seed name."""
|
||||||
return self._multidata["seed_name"]
|
return self._multidata["seed_name"]
|
||||||
|
|
||||||
def get_slot_data(self, player: int) -> Dict[str, Any]:
|
def get_slot_data(self, team: int, player: int) -> Dict[str, Any]:
|
||||||
"""Retrieves the slot data for a given player."""
|
"""Retrieves the slot data for a given player."""
|
||||||
return self._multidata["slot_data"][player]
|
return self._multidata["slot_data"][player]
|
||||||
|
|
||||||
def get_slot_info(self, player: int) -> NetworkSlot:
|
def get_slot_info(self, team: int, player: int) -> NetworkSlot:
|
||||||
"""Retrieves the NetworkSlot data for a given player."""
|
"""Retrieves the NetworkSlot data for a given player."""
|
||||||
return self._multidata["slot_info"][player]
|
return self._multidata["slot_info"][player]
|
||||||
|
|
||||||
def get_player_name(self, player: int) -> str:
|
def get_player_name(self, team: int, player: int) -> str:
|
||||||
"""Retrieves the slot name for a given player."""
|
"""Retrieves the slot name for a given player."""
|
||||||
return self.get_slot_info(player).name
|
return self.get_slot_info(team, player).name
|
||||||
|
|
||||||
def get_player_game(self, player: int) -> str:
|
def get_player_game(self, team: int, player: int) -> str:
|
||||||
"""Retrieves the game for a given player."""
|
"""Retrieves the game for a given player."""
|
||||||
return self.get_slot_info(player).game
|
return self.get_slot_info(team, player).game
|
||||||
|
|
||||||
def get_player_locations(self, player: int) -> Dict[int, ItemMetadata]:
|
def get_player_locations(self, team: int, player: int) -> Dict[int, ItemMetadata]:
|
||||||
"""Retrieves all locations with their containing item's metadata for a given player."""
|
"""Retrieves all locations with their containing item's metadata for a given player."""
|
||||||
return self._multidata["locations"][player]
|
return self._multidata["locations"][player]
|
||||||
|
|
||||||
def get_player_starting_inventory(self, player: int) -> List[int]:
|
def get_player_starting_inventory(self, team: int, player: int) -> List[int]:
|
||||||
"""Retrieves a list of all item codes a given slot starts with."""
|
"""Retrieves a list of all item codes a given slot starts with."""
|
||||||
return self._multidata["precollected_items"][player]
|
return self._multidata["precollected_items"][player]
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class TrackerData:
|
|||||||
@_cache_results
|
@_cache_results
|
||||||
def get_player_missing_locations(self, team: int, player: int) -> Set[int]:
|
def get_player_missing_locations(self, team: int, player: int) -> Set[int]:
|
||||||
"""Retrieves the set of all locations not marked complete by this player."""
|
"""Retrieves the set of all locations not marked complete by this player."""
|
||||||
return set(self.get_player_locations(player)) - self.get_player_checked_locations(team, player)
|
return set(self.get_player_locations(team, player)) - self.get_player_checked_locations(team, player)
|
||||||
|
|
||||||
def get_player_received_items(self, team: int, player: int) -> List[NetworkItem]:
|
def get_player_received_items(self, team: int, player: int) -> List[NetworkItem]:
|
||||||
"""Returns all items received to this player in order of received."""
|
"""Returns all items received to this player in order of received."""
|
||||||
@@ -125,7 +126,7 @@ class TrackerData:
|
|||||||
def get_player_inventory_counts(self, team: int, player: int) -> collections.Counter:
|
def get_player_inventory_counts(self, team: int, player: int) -> collections.Counter:
|
||||||
"""Retrieves a dictionary of all items received by their id and their received count."""
|
"""Retrieves a dictionary of all items received by their id and their received count."""
|
||||||
received_items = self.get_player_received_items(team, player)
|
received_items = self.get_player_received_items(team, player)
|
||||||
starting_items = self.get_player_starting_inventory(player)
|
starting_items = self.get_player_starting_inventory(team, player)
|
||||||
inventory = collections.Counter()
|
inventory = collections.Counter()
|
||||||
for item in received_items:
|
for item in received_items:
|
||||||
inventory[item.item] += 1
|
inventory[item.item] += 1
|
||||||
@@ -178,7 +179,7 @@ class TrackerData:
|
|||||||
def get_team_locations_total_count(self) -> Dict[int, int]:
|
def get_team_locations_total_count(self) -> Dict[int, int]:
|
||||||
"""Retrieves a dictionary of total player locations each team has."""
|
"""Retrieves a dictionary of total player locations each team has."""
|
||||||
return {
|
return {
|
||||||
team: sum(len(self.get_player_locations(player)) for player in players)
|
team: sum(len(self.get_player_locations(team, player)) for player in players)
|
||||||
for team, players in self.get_all_players().items()
|
for team, players in self.get_all_players().items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ class TrackerData:
|
|||||||
return {
|
return {
|
||||||
0: [
|
0: [
|
||||||
player for player, slot_info in self._multidata["slot_info"].items()
|
player for player, slot_info in self._multidata["slot_info"].items()
|
||||||
if self.get_slot_info(player).type == SlotType.player
|
if self.get_slot_info(0, player).type == SlotType.player
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ class TrackerData:
|
|||||||
def get_room_locations(self) -> Dict[TeamPlayer, Dict[int, ItemMetadata]]:
|
def get_room_locations(self) -> Dict[TeamPlayer, Dict[int, ItemMetadata]]:
|
||||||
"""Retrieves a dictionary of all locations and their associated item metadata per player."""
|
"""Retrieves a dictionary of all locations and their associated item metadata per player."""
|
||||||
return {
|
return {
|
||||||
(team, player): self.get_player_locations(player)
|
(team, player): self.get_player_locations(team, player)
|
||||||
for team, players in self.get_all_players().items() for player in players
|
for team, players in self.get_all_players().items() for player in players
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +234,7 @@ class TrackerData:
|
|||||||
def get_room_games(self) -> Dict[TeamPlayer, str]:
|
def get_room_games(self) -> Dict[TeamPlayer, str]:
|
||||||
"""Retrieves a dictionary of games for each player."""
|
"""Retrieves a dictionary of games for each player."""
|
||||||
return {
|
return {
|
||||||
(team, player): self.get_player_game(player)
|
(team, player): self.get_player_game(team, player)
|
||||||
for team, players in self.get_all_slots().items() for player in players
|
for team, players in self.get_all_slots().items() for player in players
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +262,9 @@ class TrackerData:
|
|||||||
for player in players:
|
for player in players:
|
||||||
alias = self.get_player_alias(team, player)
|
alias = self.get_player_alias(team, player)
|
||||||
if alias:
|
if alias:
|
||||||
long_player_names[team, player] = f"{alias} ({self.get_player_name(player)})"
|
long_player_names[team, player] = f"{alias} ({self.get_player_name(team, player)})"
|
||||||
else:
|
else:
|
||||||
long_player_names[team, player] = self.get_player_name(player)
|
long_player_names[team, player] = self.get_player_name(team, player)
|
||||||
|
|
||||||
return long_player_names
|
return long_player_names
|
||||||
|
|
||||||
@@ -343,7 +344,7 @@ def get_timeout_and_player_tracker(room: Room, tracked_team: int, tracked_player
|
|||||||
tracker_data = TrackerData(room)
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
# Load and render the game-specific player tracker, or fallback to generic tracker if none exists.
|
||||||
game_specific_tracker = _player_trackers.get(tracker_data.get_player_game(tracked_player), None)
|
game_specific_tracker = _player_trackers.get(tracker_data.get_player_game(tracked_team, tracked_player), None)
|
||||||
if game_specific_tracker and not generic:
|
if game_specific_tracker and not generic:
|
||||||
tracker = game_specific_tracker(tracker_data, tracked_team, tracked_player)
|
tracker = game_specific_tracker(tracker_data, tracked_team, tracked_player)
|
||||||
else:
|
else:
|
||||||
@@ -408,10 +409,10 @@ def get_enabled_multiworld_trackers(room: Room) -> Dict[str, Callable]:
|
|||||||
|
|
||||||
|
|
||||||
def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
||||||
game = tracker_data.get_player_game(player)
|
game = tracker_data.get_player_game(team, player)
|
||||||
|
|
||||||
received_items_in_order = {}
|
received_items_in_order = {}
|
||||||
starting_inventory = tracker_data.get_player_starting_inventory(player)
|
starting_inventory = tracker_data.get_player_starting_inventory(team, player)
|
||||||
for index, item in enumerate(starting_inventory):
|
for index, item in enumerate(starting_inventory):
|
||||||
received_items_in_order[item] = index
|
received_items_in_order[item] = index
|
||||||
for index, network_item in enumerate(tracker_data.get_player_received_items(team, player),
|
for index, network_item in enumerate(tracker_data.get_player_received_items(team, player),
|
||||||
@@ -427,7 +428,7 @@ def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) ->
|
|||||||
player=player,
|
player=player,
|
||||||
player_name=tracker_data.get_room_long_player_names()[team, player],
|
player_name=tracker_data.get_room_long_player_names()[team, player],
|
||||||
inventory=tracker_data.get_player_inventory_counts(team, player),
|
inventory=tracker_data.get_player_inventory_counts(team, player),
|
||||||
locations=tracker_data.get_player_locations(player),
|
locations=tracker_data.get_player_locations(team, player),
|
||||||
checked_locations=tracker_data.get_player_checked_locations(team, player),
|
checked_locations=tracker_data.get_player_checked_locations(team, player),
|
||||||
received_items=received_items_in_order,
|
received_items=received_items_in_order,
|
||||||
saving_second=tracker_data.get_room_saving_second(),
|
saving_second=tracker_data.get_room_saving_second(),
|
||||||
@@ -499,7 +500,7 @@ if "Factorio" in network_data_package["games"]:
|
|||||||
tracker_data.item_id_to_name["Factorio"][item_id]: count
|
tracker_data.item_id_to_name["Factorio"][item_id]: count
|
||||||
for item_id, count in tracker_data.get_player_inventory_counts(team, player).items()
|
for item_id, count in tracker_data.get_player_inventory_counts(team, player).items()
|
||||||
}) for team, players in tracker_data.get_all_players().items() for player in players
|
}) for team, players in tracker_data.get_all_players().items() for player in players
|
||||||
if tracker_data.get_player_game(player) == "Factorio"
|
if tracker_data.get_player_game(team, player) == "Factorio"
|
||||||
}
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -588,7 +589,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
|||||||
|
|
||||||
# Highlight 'bombs' if we received any bomb upgrades in bombless start.
|
# Highlight 'bombs' if we received any bomb upgrades in bombless start.
|
||||||
# In race mode, we'll just assume bombless start for simplicity.
|
# In race mode, we'll just assume bombless start for simplicity.
|
||||||
if tracker_data.get_slot_data(player).get("bombless_start", True):
|
if tracker_data.get_slot_data(team, player).get("bombless_start", True):
|
||||||
inventory["Bombs"] = sum(count for item, count in inventory.items() if item.startswith("Bomb Upgrade"))
|
inventory["Bombs"] = sum(count for item, count in inventory.items() if item.startswith("Bomb Upgrade"))
|
||||||
else:
|
else:
|
||||||
inventory["Bombs"] = 1
|
inventory["Bombs"] = 1
|
||||||
@@ -604,7 +605,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
|||||||
for code, count in tracker_data.get_player_inventory_counts(team, player).items()
|
for code, count in tracker_data.get_player_inventory_counts(team, player).items()
|
||||||
})
|
})
|
||||||
for team, players in tracker_data.get_all_players().items()
|
for team, players in tracker_data.get_all_players().items()
|
||||||
for player in players if tracker_data.get_slot_info(player).game == "A Link to the Past"
|
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Translate non-progression items to progression items for tracker simplicity.
|
# Translate non-progression items to progression items for tracker simplicity.
|
||||||
@@ -623,7 +624,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
|||||||
for region_name in known_regions
|
for region_name in known_regions
|
||||||
}
|
}
|
||||||
for team, players in tracker_data.get_all_players().items()
|
for team, players in tracker_data.get_all_players().items()
|
||||||
for player in players if tracker_data.get_slot_info(player).game == "A Link to the Past"
|
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get a totals count.
|
# Get a totals count.
|
||||||
@@ -697,7 +698,7 @@ if "A Link to the Past" in network_data_package["games"]:
|
|||||||
team=team,
|
team=team,
|
||||||
player=player,
|
player=player,
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
regions=regions,
|
regions=regions,
|
||||||
known_regions=known_regions,
|
known_regions=known_regions,
|
||||||
)
|
)
|
||||||
@@ -844,7 +845,7 @@ if "Ocarina of Time" in network_data_package["games"]:
|
|||||||
return full_name[len(area):]
|
return full_name[len(area):]
|
||||||
return full_name
|
return full_name
|
||||||
|
|
||||||
locations = tracker_data.get_player_locations(player)
|
locations = tracker_data.get_player_locations(team, player)
|
||||||
checked_locations = tracker_data.get_player_checked_locations(team, player).intersection(set(locations))
|
checked_locations = tracker_data.get_player_checked_locations(team, player).intersection(set(locations))
|
||||||
location_info = {}
|
location_info = {}
|
||||||
checks_done = {}
|
checks_done = {}
|
||||||
@@ -906,7 +907,7 @@ if "Ocarina of Time" in network_data_package["games"]:
|
|||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
room=tracker_data.room,
|
room=tracker_data.room,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
icons=icons,
|
icons=icons,
|
||||||
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
|
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
|
||||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||||
@@ -954,36 +955,56 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.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",
|
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
||||||
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||||
"Cube of Bodie": "https://timespinnerwiki.com/mediawiki/images/1/14/Menu_Icon_Stats.png"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
"Present": list(range(1337000, 1337085)),
|
"Present": [
|
||||||
"Past": list(range(1337086, 1337175)),
|
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
||||||
|
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
||||||
|
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
||||||
|
1337030, 1337031, 1337032, 1337033, 1337034, 1337035, 1337036, 1337037, 1337038, 1337039,
|
||||||
|
1337040, 1337041, 1337042, 1337043, 1337044, 1337045, 1337046, 1337047, 1337048, 1337049,
|
||||||
|
1337050, 1337051, 1337052, 1337053, 1337054, 1337055, 1337056, 1337057, 1337058, 1337059,
|
||||||
|
1337060, 1337061, 1337062, 1337063, 1337064, 1337065, 1337066, 1337067, 1337068, 1337069,
|
||||||
|
1337070, 1337071, 1337072, 1337073, 1337074, 1337075, 1337076, 1337077, 1337078, 1337079,
|
||||||
|
1337080, 1337081, 1337082, 1337083, 1337084, 1337085],
|
||||||
|
"Past": [
|
||||||
|
1337086, 1337087, 1337088, 1337089,
|
||||||
|
1337090, 1337091, 1337092, 1337093, 1337094, 1337095, 1337096, 1337097, 1337098, 1337099,
|
||||||
|
1337100, 1337101, 1337102, 1337103, 1337104, 1337105, 1337106, 1337107, 1337108, 1337109,
|
||||||
|
1337110, 1337111, 1337112, 1337113, 1337114, 1337115, 1337116, 1337117, 1337118, 1337119,
|
||||||
|
1337120, 1337121, 1337122, 1337123, 1337124, 1337125, 1337126, 1337127, 1337128, 1337129,
|
||||||
|
1337130, 1337131, 1337132, 1337133, 1337134, 1337135, 1337136, 1337137, 1337138, 1337139,
|
||||||
|
1337140, 1337141, 1337142, 1337143, 1337144, 1337145, 1337146, 1337147, 1337148, 1337149,
|
||||||
|
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
||||||
|
1337171, 1337172, 1337173, 1337174, 1337175],
|
||||||
"Ancient Pyramid": [
|
"Ancient Pyramid": [
|
||||||
1337236,
|
1337236,
|
||||||
1337246, 1337247, 1337248, 1337249]
|
1337246, 1337247, 1337248, 1337249]
|
||||||
}
|
}
|
||||||
|
|
||||||
slot_data = tracker_data.get_slot_data(player)
|
slot_data = tracker_data.get_slot_data(team, player)
|
||||||
if (slot_data["DownloadableItems"]):
|
if (slot_data["DownloadableItems"]):
|
||||||
timespinner_location_ids["Present"] += [1337156, 1337157] + list(range(1337159, 1337170))
|
timespinner_location_ids["Present"] += [
|
||||||
|
1337156, 1337157, 1337159,
|
||||||
|
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||||
|
1337170]
|
||||||
if (slot_data["Cantoran"]):
|
if (slot_data["Cantoran"]):
|
||||||
timespinner_location_ids["Past"].append(1337176)
|
timespinner_location_ids["Past"].append(1337176)
|
||||||
if (slot_data["LoreChecks"]):
|
if (slot_data["LoreChecks"]):
|
||||||
timespinner_location_ids["Present"] += list(range(1337177, 1337187))
|
timespinner_location_ids["Present"] += [
|
||||||
timespinner_location_ids["Past"] += list(range(1337188, 1337198))
|
1337177, 1337178, 1337179,
|
||||||
|
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
||||||
|
timespinner_location_ids["Past"] += [
|
||||||
|
1337188, 1337189,
|
||||||
|
1337190, 1337191, 1337192, 1337193, 1337194, 1337195, 1337196, 1337197, 1337198]
|
||||||
if (slot_data["GyreArchives"]):
|
if (slot_data["GyreArchives"]):
|
||||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337237, 1337245))
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
|
1337237, 1337238, 1337239,
|
||||||
|
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||||
if (slot_data["PyramidStart"]):
|
if (slot_data["PyramidStart"]):
|
||||||
timespinner_location_ids["Ancient Pyramid"] += [
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
1337233, 1337234, 1337235]
|
1337233, 1337234, 1337235]
|
||||||
if (slot_data["PureTorcher"]):
|
|
||||||
timespinner_location_ids["Present"] += list(range(1337250, 1337352)) + list(range(1337422, 1337496)) + [1337506] + list(range(1337712, 1337779)) + [1337781, 1337782]
|
|
||||||
timespinner_location_ids["Past"] += list(range(1337497, 1337505)) + list(range(1337507, 1337711)) + [1337780]
|
|
||||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337369, 1337421))
|
|
||||||
if (slot_data["GyreArchives"]):
|
|
||||||
timespinner_location_ids["Ancient Pyramid"] += list(range(1337353, 1337368))
|
|
||||||
|
|
||||||
display_data = {}
|
display_data = {}
|
||||||
|
|
||||||
@@ -1014,7 +1035,7 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
room=tracker_data.room,
|
room=tracker_data.room,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
checks_done=checks_done,
|
checks_done=checks_done,
|
||||||
checks_in_area=checks_in_area,
|
checks_in_area=checks_in_area,
|
||||||
location_info=location_info,
|
location_info=location_info,
|
||||||
@@ -1123,7 +1144,7 @@ if "Super Metroid" in network_data_package["games"]:
|
|||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
room=tracker_data.room,
|
room=tracker_data.room,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
checks_done=checks_done,
|
checks_done=checks_done,
|
||||||
checks_in_area=checks_in_area,
|
checks_in_area=checks_in_area,
|
||||||
location_info=location_info,
|
location_info=location_info,
|
||||||
@@ -1173,7 +1194,7 @@ if "ChecksFinder" in network_data_package["games"]:
|
|||||||
|
|
||||||
display_data = {}
|
display_data = {}
|
||||||
inventory = tracker_data.get_player_inventory_counts(team, player)
|
inventory = tracker_data.get_player_inventory_counts(team, player)
|
||||||
locations = tracker_data.get_player_locations(player)
|
locations = tracker_data.get_player_locations(team, player)
|
||||||
|
|
||||||
# Multi-items
|
# Multi-items
|
||||||
multi_items = {
|
multi_items = {
|
||||||
@@ -1215,7 +1236,7 @@ if "ChecksFinder" in network_data_package["games"]:
|
|||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
room=tracker_data.room,
|
room=tracker_data.room,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
checks_done=checks_done,
|
checks_done=checks_done,
|
||||||
checks_in_area=checks_in_area,
|
checks_in_area=checks_in_area,
|
||||||
location_info=location_info,
|
location_info=location_info,
|
||||||
@@ -1243,7 +1264,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
|||||||
UPGRADE_RESEARCH_SPEED_ITEM_ID = 1807
|
UPGRADE_RESEARCH_SPEED_ITEM_ID = 1807
|
||||||
UPGRADE_RESEARCH_COST_ITEM_ID = 1808
|
UPGRADE_RESEARCH_COST_ITEM_ID = 1808
|
||||||
REDUCED_MAX_SUPPLY_ITEM_ID = 1850
|
REDUCED_MAX_SUPPLY_ITEM_ID = 1850
|
||||||
slot_data = tracker_data.get_slot_data(player)
|
slot_data = tracker_data.get_slot_data(team, player)
|
||||||
inventory: collections.Counter[int] = tracker_data.get_player_inventory_counts(team, player)
|
inventory: collections.Counter[int] = tracker_data.get_player_inventory_counts(team, player)
|
||||||
item_id_to_name = tracker_data.item_id_to_name["Starcraft 2"]
|
item_id_to_name = tracker_data.item_id_to_name["Starcraft 2"]
|
||||||
location_id_to_name = tracker_data.location_id_to_name["Starcraft 2"]
|
location_id_to_name = tracker_data.location_id_to_name["Starcraft 2"]
|
||||||
@@ -1262,7 +1283,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
|||||||
|
|
||||||
# Locations
|
# Locations
|
||||||
have_nco_locations = False
|
have_nco_locations = False
|
||||||
locations = tracker_data.get_player_locations(player)
|
locations = tracker_data.get_player_locations(team, player)
|
||||||
checked_locations = tracker_data.get_player_checked_locations(team, player)
|
checked_locations = tracker_data.get_player_checked_locations(team, player)
|
||||||
missions: dict[str, list[tuple[str, bool]]] = {}
|
missions: dict[str, list[tuple[str, bool]]] = {}
|
||||||
for location_id in locations:
|
for location_id in locations:
|
||||||
@@ -1435,7 +1456,7 @@ if "Starcraft 2" in network_data_package["games"]:
|
|||||||
player=player,
|
player=player,
|
||||||
team=team,
|
team=team,
|
||||||
room=tracker_data.room,
|
room=tracker_data.room,
|
||||||
player_name=tracker_data.get_player_name(player),
|
player_name=tracker_data.get_player_name(team, player),
|
||||||
missions=missions,
|
missions=missions,
|
||||||
locations=locations,
|
locations=locations,
|
||||||
checked_locations=checked_locations,
|
checked_locations=checked_locations,
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ description: {{ yaml_dump("Default %s Template" % game) }}
|
|||||||
game: {{ yaml_dump(game) }}
|
game: {{ yaml_dump(game) }}
|
||||||
requires:
|
requires:
|
||||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||||
{%- if world_version != "0.0.0" %}
|
|
||||||
game:
|
|
||||||
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
|
|
||||||
{%- endif %}
|
|
||||||
|
|
||||||
{%- macro range_option(option) %}
|
{%- macro range_option(option) %}
|
||||||
# You can define additional values between the minimum and maximum values.
|
# You can define additional values between the minimum and maximum values.
|
||||||
|
|||||||
@@ -72,9 +72,6 @@
|
|||||||
# Faxanadu
|
# Faxanadu
|
||||||
/worlds/faxanadu/ @Daivuk
|
/worlds/faxanadu/ @Daivuk
|
||||||
|
|
||||||
# Final Fantasy (1)
|
|
||||||
/worlds/ff1/ @Rosalie-A
|
|
||||||
|
|
||||||
# Final Fantasy Mystic Quest
|
# Final Fantasy Mystic Quest
|
||||||
/worlds/ffmq/ @Alchav @wildham0
|
/worlds/ffmq/ @Alchav @wildham0
|
||||||
|
|
||||||
@@ -244,6 +241,9 @@
|
|||||||
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
||||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||||
|
|
||||||
|
# Final Fantasy (1)
|
||||||
|
# /worlds/ff1/
|
||||||
|
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
# /worlds/oot/
|
# /worlds/oot/
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,26 @@
|
|||||||
# APWorld Specification
|
# apworld Specification
|
||||||
|
|
||||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||||
These are called "APWorlds".
|
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||||
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
|
||||||
See [world api.md](world%20api.md) for details.
|
See [world api.md](world%20api.md) for details.
|
||||||
APWorlds can either be a folder, or they can be packaged as an .apworld file.
|
|
||||||
|
|
||||||
## .apworld File Format
|
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||||
|
file into the worlds folder.
|
||||||
|
|
||||||
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
|
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||||
by placing a `*.apworld` file into the worlds folder.
|
|
||||||
|
|
||||||
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
apworld files are zip archives, all lower case, with the file ending `.apworld`.
|
||||||
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
||||||
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
||||||
|
|
||||||
**Warning:** `.apworld` files have to be all lower case,
|
|
||||||
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
Metadata about the APWorld is defined in an `archipelago.json` file.
|
No metadata is specified yet.
|
||||||
|
|
||||||
If the APWorld is a folder, the only required field is "game":
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"game": "Game Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
There are also the following optional fields:
|
|
||||||
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
|
||||||
Archipelago version respectively to filter those files from being loaded.
|
|
||||||
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
|
||||||
An APWorld without a world_version is always treated as older than one with a version
|
|
||||||
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
|
||||||
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
|
|
||||||
package managers. Should always be a list of strings.
|
|
||||||
|
|
||||||
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
|
||||||
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
|
||||||
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
|
|
||||||
["Build apworlds" launcher component](#build-apworlds-launcher-component),
|
|
||||||
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
|
||||||
|
|
||||||
### "Build apworlds" Launcher Component
|
|
||||||
|
|
||||||
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
|
|
||||||
and add `archipelago.json` manifest files to them.
|
|
||||||
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
|
||||||
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
|
||||||
`version` and `compatible_version`.
|
|
||||||
|
|
||||||
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
|
|
||||||
So, a world folder with an `archipelago.json` that looks like this:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"game": "Game Name",
|
|
||||||
"minimum_ap_version": "0.6.4",
|
|
||||||
"world_version": "2.1.4",
|
|
||||||
"authors": ["NewSoupVi"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"minimum_ap_version": "0.6.4",
|
|
||||||
"world_version": "2.1.4",
|
|
||||||
"authors": ["NewSoupVi"],
|
|
||||||
"version": 7,
|
|
||||||
"compatible_version": 7,
|
|
||||||
"game": "Game Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the recommended workflow for packaging your world to an `.apworld`.
|
|
||||||
|
|
||||||
## Extra Data
|
## Extra Data
|
||||||
|
|
||||||
@@ -86,7 +29,7 @@ The zip can contain arbitrary files in addition what was specified above.
|
|||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
|
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||||
|
|
||||||
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
||||||
`from worlds.AutoWorld import World`
|
`from worlds.AutoWorld import World`
|
||||||
|
|||||||
@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
|
|||||||
|
|
||||||
Terrain matching or dungeon shuffle:
|
Terrain matching or dungeon shuffle:
|
||||||
```python
|
```python
|
||||||
def randomize_within_same_group(group: int) -> list[int]:
|
def randomize_within_same_group(group: int) -> List[int]:
|
||||||
return [group]
|
return [group]
|
||||||
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
||||||
```
|
```
|
||||||
|
|
||||||
Directional + area shuffle:
|
Directional + area shuffle:
|
||||||
```python
|
```python
|
||||||
def get_target_groups(group: int) -> list[int]:
|
def get_target_groups(group: int) -> List[int]:
|
||||||
# example group: LEFT | CAVE
|
# example group: LEFT | CAVE
|
||||||
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
||||||
direction = group & Groups.DIRECTION_MASK
|
direction = group & Groups.DIRECTION_MASK
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Sent to clients when they connect to an Archipelago server.
|
|||||||
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
|
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
|
||||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
||||||
| password | bool | Denoted whether a password is required to join this room. |
|
| password | bool | Denoted whether a password is required to join this room. |
|
||||||
| permissions | dict\[str, [Permission](#Permission)\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||||
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
||||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
||||||
| games | list\[str\] | List of games present in this multiworld. |
|
| games | list\[str\] | List of games present in this multiworld. |
|
||||||
@@ -662,14 +662,13 @@ class SlotType(enum.IntFlag):
|
|||||||
An object representing static information about a slot.
|
An object representing static information about a slot.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from collections.abc import Sequence
|
import typing
|
||||||
from typing import NamedTuple
|
|
||||||
from NetUtils import SlotType
|
from NetUtils import SlotType
|
||||||
class NetworkSlot(NamedTuple):
|
class NetworkSlot(typing.NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: Sequence[int] = [] # only populated if type == group
|
group_members: typing.List[int] = [] # only populated if type == group
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission
|
### Permission
|
||||||
@@ -687,8 +686,8 @@ class Permission(enum.IntEnum):
|
|||||||
### Hint
|
### Hint
|
||||||
An object representing a Hint.
|
An object representing a Hint.
|
||||||
```python
|
```python
|
||||||
from typing import NamedTuple
|
import typing
|
||||||
class Hint(NamedTuple):
|
class Hint(typing.NamedTuple):
|
||||||
receiving_player: int
|
receiving_player: int
|
||||||
finding_player: int
|
finding_player: int
|
||||||
location: int
|
location: int
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if it does not exist.
|
|||||||
## Global Settings
|
## Global Settings
|
||||||
|
|
||||||
All non-world-specific settings are defined directly in settings.py.
|
All non-world-specific settings are defined directly in settings.py.
|
||||||
Each value needs to have a default. If the default should be `None`, annotate it using `T | None = None`.
|
Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`.
|
||||||
|
|
||||||
To access a "global" config value, with correct typing, use one of
|
To access a "global" config value, with correct typing, use one of
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -15,10 +15,8 @@
|
|||||||
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
* Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation,
|
||||||
use single quotes inside them: `f"Like {dct['key']}"`
|
use single quotes inside them: `f"Like {dct['key']}"`
|
||||||
* Use type annotations where possible for function signatures and class members.
|
* Use type annotations where possible for function signatures and class members.
|
||||||
* Use type annotations where appropriate for local variables (e.g. `var: list[int] = []`, or when the
|
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
|
||||||
type is hard or impossible to deduce). Clear annotations help developers look up and validate API calls.
|
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
|
||||||
* Prefer new style type annotations for new code (e.g. `var: dict[str, str | int]` over
|
|
||||||
`var: Dict[str, Union[str, int]]`).
|
|
||||||
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
|
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
|
||||||
beginning of a line at the same indentation as the beginning of the line with the open bracket.
|
beginning of a line at the same indentation as the beginning of the line with the open bracket.
|
||||||
```python
|
```python
|
||||||
@@ -62,9 +60,3 @@
|
|||||||
* Indent `case` inside `switch ` with 2 spaces.
|
* Indent `case` inside `switch ` with 2 spaces.
|
||||||
* Use single quotes.
|
* Use single quotes.
|
||||||
* Semicolons are required after every statement.
|
* Semicolons are required after every statement.
|
||||||
|
|
||||||
## KV
|
|
||||||
|
|
||||||
* Style should be defined in `.kv` as much as possible, only Python when unavailable.
|
|
||||||
* Should follow [our Python style](#python-code) where appropriate (quotation marks, indentation).
|
|
||||||
* When escaping a line break, add a space between code and backslash.
|
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ overridden. For more information on what methods are available to your class, ch
|
|||||||
|
|
||||||
#### Alternatives to WorldTestBase
|
#### Alternatives to WorldTestBase
|
||||||
|
|
||||||
Unit tests can also be created using
|
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
||||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) directly. These may be useful
|
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
||||||
for generating a multiworld under very specific constraints without using the generic world setup, or for testing
|
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
||||||
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
|
#### Parametrization
|
||||||
|
|
||||||
@@ -102,7 +102,8 @@ for multiple inputs) the base test. Some important things to consider when attem
|
|||||||
|
|
||||||
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
|
||||||
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
|
||||||
extra CPU time. Consider using `unittest.TestCase` directly or setting `WorldTestBase.run_default_tests` to False.
|
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
|
||||||
|
or setting `WorldTestBase.run_default_tests` to False.
|
||||||
|
|
||||||
#### Performance Considerations
|
#### Performance Considerations
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ Current endpoints:
|
|||||||
- [`/room_status/<suuid:room_id>`](#roomstatus)
|
- [`/room_status/<suuid:room_id>`](#roomstatus)
|
||||||
- Tracker API
|
- Tracker API
|
||||||
- [`/tracker/<suuid:tracker>`](#tracker)
|
- [`/tracker/<suuid:tracker>`](#tracker)
|
||||||
- [`/static_tracker/<suuid:tracker>`](#statictracker)
|
|
||||||
- [`/slot_data_tracker/<suuid:tracker>`](#slotdatatracker)
|
|
||||||
- User API
|
- User API
|
||||||
- [`/get_rooms`](#getrooms)
|
- [`/get_rooms`](#getrooms)
|
||||||
- [`/get_seeds`](#getseeds)
|
- [`/get_seeds`](#getseeds)
|
||||||
@@ -256,6 +254,8 @@ can either be viewed while on a room tracker page, or from the [room's endpoint]
|
|||||||
<a name=tracker></a>
|
<a name=tracker></a>
|
||||||
Will provide a dict of tracker data with the following keys:
|
Will provide a dict of tracker data with the following keys:
|
||||||
|
|
||||||
|
- item_link groups and their players (`groups`)
|
||||||
|
- Each player's slot_data (`slot_data`)
|
||||||
- Each player's current alias (`aliases`)
|
- Each player's current alias (`aliases`)
|
||||||
- Will return the name if there is none
|
- Will return the name if there is none
|
||||||
- A list of items each player has received as a NetworkItem (`player_items_received`)
|
- A list of items each player has received as a NetworkItem (`player_items_received`)
|
||||||
@@ -265,55 +265,111 @@ Will provide a dict of tracker data with the following keys:
|
|||||||
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
|
- The time of last activity of each player in RFC 1123 format (`activity_timers`)
|
||||||
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
|
- The time of last active connection of each player in RFC 1123 format (`connection_timers`)
|
||||||
- The current client status of each player (`player_status`)
|
- The current client status of each player (`player_status`)
|
||||||
|
- The datapackage hash for each player (`datapackage`)
|
||||||
|
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
|
||||||
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"slot": 5,
|
||||||
|
"name": "testGroup",
|
||||||
|
"members": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 6,
|
||||||
|
"name": "myCoolLink",
|
||||||
|
"members": [
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"slot_data": [
|
||||||
|
{
|
||||||
|
"team": 0,
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"player": 1,
|
||||||
|
"slot_data": {
|
||||||
|
"example_option": 1,
|
||||||
|
"other_option": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player": 2,
|
||||||
|
"slot_data": {
|
||||||
|
"example_option": 1,
|
||||||
|
"other_option": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"aliases": [
|
"aliases": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"alias": "Incompetence"
|
{
|
||||||
},
|
"player": 1,
|
||||||
{
|
"alias": "Incompetence"
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"alias": "Slot_Name_2"
|
"player": 2,
|
||||||
|
"alias": "Slot_Name_2"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"player_items_received": [
|
"player_items_received": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"items": [
|
{
|
||||||
[1, 1, 1, 0],
|
"player": 1,
|
||||||
[2, 2, 2, 1]
|
"items": [
|
||||||
]
|
[1, 1, 1, 0],
|
||||||
},
|
[2, 2, 2, 1]
|
||||||
{
|
]
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"items": [
|
"player": 2,
|
||||||
[1, 1, 1, 2],
|
"items": [
|
||||||
[2, 2, 2, 0]
|
[1, 1, 1, 2],
|
||||||
|
[2, 2, 2, 0]
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"player_checks_done": [
|
"player_checks_done": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"locations": [
|
{
|
||||||
1,
|
"player": 1,
|
||||||
2
|
"locations": [
|
||||||
]
|
1,
|
||||||
},
|
2
|
||||||
{
|
]
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"locations": [
|
"player": 2,
|
||||||
1,
|
"locations": [
|
||||||
2
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -326,132 +382,76 @@ Example:
|
|||||||
"hints": [
|
"hints": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"hints": [
|
{
|
||||||
[1, 2, 4, 6, 0, "", 4, 0]
|
"player": 1,
|
||||||
|
"hints": [
|
||||||
|
[1, 2, 4, 6, 0, "", 4, 0]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player": 2,
|
||||||
|
"hints": []
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"team": 0,
|
|
||||||
"player": 2,
|
|
||||||
"hints": []
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"activity_timers": [
|
"activity_timers": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
|
{
|
||||||
},
|
"player": 1,
|
||||||
{
|
"time": "Fri, 18 Apr 2025 20:35:45 GMT"
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
|
"player": 2,
|
||||||
|
"time": "Fri, 18 Apr 2025 20:42:46 GMT"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connection_timers": [
|
"connection_timers": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
|
{
|
||||||
},
|
"player": 1,
|
||||||
{
|
"time": "Fri, 18 Apr 2025 20:38:25 GMT"
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
|
"player": 2,
|
||||||
|
"time": "Fri, 18 Apr 2025 21:03:00 GMT"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"player_status": [
|
"player_status": [
|
||||||
{
|
{
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"player": 1,
|
"players": [
|
||||||
"status": 0
|
{
|
||||||
},
|
"player": 1,
|
||||||
{
|
"status": 0
|
||||||
"team": 0,
|
},
|
||||||
"player": 2,
|
{
|
||||||
"status": 0
|
"player": 2,
|
||||||
}
|
"status": 0
|
||||||
]
|
}
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `/static_tracker/<suuid:tracker>`
|
|
||||||
<a name=statictracker></a>
|
|
||||||
Will provide a dict of static tracker data with the following keys:
|
|
||||||
|
|
||||||
- item_link groups and their players (`groups`)
|
|
||||||
- The datapackage hash for each game (`datapackage`)
|
|
||||||
- This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary
|
|
||||||
- The number of checks found vs. total checks available per player (`player_locations_total`)
|
|
||||||
- Same logic as the multitracker template: found = len(player_checks_done.locations) / total = player_locations_total.total_locations (all available checks).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"slot": 5,
|
|
||||||
"name": "testGroup",
|
|
||||||
"members": [
|
|
||||||
1,
|
|
||||||
2
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slot": 6,
|
|
||||||
"name": "myCoolLink",
|
|
||||||
"members": [
|
|
||||||
3,
|
|
||||||
4
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"datapackage": {
|
"datapackage": {
|
||||||
"Archipelago": {
|
"Archipelago": {
|
||||||
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
|
"checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb",
|
||||||
|
"version": 0
|
||||||
},
|
},
|
||||||
"The Messenger": {
|
"The Messenger": {
|
||||||
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
|
"checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b",
|
||||||
}
|
"version": 0
|
||||||
},
|
|
||||||
"player_locations_total": [
|
|
||||||
{
|
|
||||||
"player": 1,
|
|
||||||
"team" : 0,
|
|
||||||
"total_locations": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"player": 2,
|
|
||||||
"team" : 0,
|
|
||||||
"total_locations": 20
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `/slot_data_tracker/<suuid:tracker>`
|
|
||||||
<a name=slotdatatracker></a>
|
|
||||||
Will provide a list of each player's slot_data.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"player": 1,
|
|
||||||
"slot_data": {
|
|
||||||
"example_option": 1,
|
|
||||||
"other_option": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"player": 2,
|
|
||||||
"slot_data": {
|
|
||||||
"example_option": 1,
|
|
||||||
"other_option": 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## User Endpoints
|
## User Endpoints
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ webhost:
|
|||||||
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
||||||
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
||||||
|
|
||||||
* `options_presets` (optional) `dict[str, dict[str, Any]]` where the keys are the names of the presets and the values
|
* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
|
||||||
are the options to be set for that preset. The options are defined as a `dict[str, Any]` where the keys are the names
|
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names
|
||||||
of the options and the values are the values to be set for that option. These presets will be available for users to
|
of the options and the values are the values to be set for that option. These presets will be available for users to
|
||||||
select from on the game's options page.
|
select from on the game's options page.
|
||||||
|
|
||||||
@@ -753,7 +753,7 @@ from BaseClasses import CollectionState, MultiWorld
|
|||||||
from worlds.AutoWorld import LogicMixin
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
class MyGameState(LogicMixin):
|
class MyGameState(LogicMixin):
|
||||||
mygame_defeatable_enemies: dict[int, set[str]] # per player
|
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
|
||||||
|
|
||||||
def init_mixin(self, multiworld: MultiWorld) -> None:
|
def init_mixin(self, multiworld: MultiWorld) -> None:
|
||||||
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
|
||||||
@@ -882,11 +882,11 @@ item/location pairs is unnecessary since the AP server already retains and freel
|
|||||||
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def fill_slot_data(self) -> dict[str, Any]:
|
def fill_slot_data(self) -> Dict[str, Any]:
|
||||||
# In order for our game client to handle the generated seed correctly we need to know what the user selected
|
# In order for our game client to handle the generated seed correctly we need to know what the user selected
|
||||||
# for their difficulty and final boss HP.
|
# for their difficulty and final boss HP.
|
||||||
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
|
# A dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting.
|
||||||
# The options dataclass has a method to return a `dict[str, Any]` of each option name provided and the relevant
|
# The options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the relevant
|
||||||
# option's value.
|
# option's value.
|
||||||
return self.options.as_dict("difficulty", "final_boss_hp")
|
return self.options.as_dict("difficulty", "final_boss_hp")
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
|
|||||||
|
|
||||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
|
|||||||
21
kvui.py
21
kvui.py
@@ -34,17 +34,6 @@ from kivy.config import Config
|
|||||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
Config.set("kivy", "exit_on_escape", "0")
|
Config.set("kivy", "exit_on_escape", "0")
|
||||||
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
||||||
|
|
||||||
# Workaround for an issue where importing kivy.core.window before loading sounds
|
|
||||||
# will hang the whole application on Linux once the first sound is loaded.
|
|
||||||
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
|
|
||||||
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
|
|
||||||
from kivy.core.audio import SoundLoader
|
|
||||||
for classobj in SoundLoader._classes:
|
|
||||||
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
|
|
||||||
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
|
|
||||||
classobj.extensions()
|
|
||||||
|
|
||||||
from kivymd.uix.divider import MDDivider
|
from kivymd.uix.divider import MDDivider
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.core.clipboard import Clipboard
|
from kivy.core.clipboard import Clipboard
|
||||||
@@ -849,15 +838,15 @@ class GameManager(ThemedApp):
|
|||||||
self.log_panels: typing.Dict[str, Widget] = {}
|
self.log_panels: typing.Dict[str, Widget] = {}
|
||||||
|
|
||||||
# keep track of last used command to autofill on click
|
# keep track of last used command to autofill on click
|
||||||
self.last_autofillable_command = "!hint"
|
self.last_autofillable_command = "hint"
|
||||||
autofillable_commands = ("!hint_location", "!hint", "!getitem")
|
autofillable_commands = ("hint_location", "hint", "getitem")
|
||||||
original_say = ctx.on_user_say
|
original_say = ctx.on_user_say
|
||||||
|
|
||||||
def intercept_say(text):
|
def intercept_say(text):
|
||||||
text = original_say(text)
|
text = original_say(text)
|
||||||
if text:
|
if text:
|
||||||
for command in autofillable_commands:
|
for command in autofillable_commands:
|
||||||
if text.startswith(command):
|
if text.startswith("!" + command):
|
||||||
self.last_autofillable_command = command
|
self.last_autofillable_command = command
|
||||||
break
|
break
|
||||||
return text
|
return text
|
||||||
@@ -1110,6 +1099,10 @@ class GameManager(ThemedApp):
|
|||||||
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
|
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
|
||||||
self.hint_log.refresh_hints(hints)
|
self.hint_log.refresh_hints(hints)
|
||||||
|
|
||||||
|
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
|
||||||
|
def open_settings(self, *largs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class LogtoUI(logging.Handler):
|
class LogtoUI(logging.Handler):
|
||||||
def __init__(self, on_log):
|
def __init__(self, on_log):
|
||||||
|
|||||||
16
ruff.toml
16
ruff.toml
@@ -1,16 +0,0 @@
|
|||||||
line-length = 120
|
|
||||||
indent-width = 4
|
|
||||||
target-version = "py311"
|
|
||||||
|
|
||||||
[lint]
|
|
||||||
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
|
|
||||||
ignore = [
|
|
||||||
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
|
|
||||||
"C901", # Author disagrees with limiting branch complexity
|
|
||||||
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
|
|
||||||
"PLC0415", # In AP, we consider local imports totally fine & necessary
|
|
||||||
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
|
|
||||||
"PLC1901", # This is just not equivalent
|
|
||||||
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
|
|
||||||
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
|
|
||||||
]
|
|
||||||
12
settings.py
12
settings.py
@@ -579,17 +579,6 @@ class ServerOptions(Group):
|
|||||||
"goal" -> Client can ask for remaining items after goal completion
|
"goal" -> Client can ask for remaining items after goal completion
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class CountdownMode(str):
|
|
||||||
"""
|
|
||||||
Countdown modes
|
|
||||||
Determines whether or not a player can initiate a countdown with !countdown
|
|
||||||
Note that /countdown is always available to the host.
|
|
||||||
|
|
||||||
"enabled" -> Client can always initiate a countdown with !countdown.
|
|
||||||
"disabled" -> Client can never initiate a countdown with !countdown.
|
|
||||||
"auto" -> !countdown will be available for any room with less than 30 slots.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class AutoShutdown(int):
|
class AutoShutdown(int):
|
||||||
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
||||||
|
|
||||||
@@ -624,7 +613,6 @@ class ServerOptions(Group):
|
|||||||
release_mode: ReleaseMode = ReleaseMode("auto")
|
release_mode: ReleaseMode = ReleaseMode("auto")
|
||||||
collect_mode: CollectMode = CollectMode("auto")
|
collect_mode: CollectMode = CollectMode("auto")
|
||||||
remaining_mode: RemainingMode = RemainingMode("goal")
|
remaining_mode: RemainingMode = RemainingMode("goal")
|
||||||
countdown_mode: CountdownMode = CountdownMode("auto")
|
|
||||||
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
||||||
compatibility: Compatibility = Compatibility(2)
|
compatibility: Compatibility = Compatibility(2)
|
||||||
log_network: LogNetwork = LogNetwork(0)
|
log_network: LogNetwork = LogNetwork(0)
|
||||||
|
|||||||
41
setup.py
41
setup.py
@@ -22,7 +22,7 @@ SNI_VERSION = "v0.0.100" # change back to "latest" once tray icon issues are fi
|
|||||||
|
|
||||||
|
|
||||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||||
requirement = 'cx-Freeze==8.4.0'
|
requirement = 'cx-Freeze==8.0.0'
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
try:
|
try:
|
||||||
@@ -146,16 +146,7 @@ def download_SNI() -> None:
|
|||||||
|
|
||||||
signtool: str | None = None
|
signtool: str | None = None
|
||||||
try:
|
try:
|
||||||
import socket
|
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
|
||||||
|
|
||||||
sign_host, sign_port = "192.168.206.4", 12345
|
|
||||||
# check if the sign_host is on a local network
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.connect((sign_host, sign_port))
|
|
||||||
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
|
|
||||||
raise ConnectionError() # would go through default route
|
|
||||||
# configure signtool
|
|
||||||
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
|
|
||||||
html = response.read()
|
html = response.read()
|
||||||
if b"status=OK\n" in html:
|
if b"status=OK\n" in html:
|
||||||
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||||
@@ -380,7 +371,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
|
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
|
||||||
from Options import generate_yaml_templates
|
from Options import generate_yaml_templates
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.Files import APWorldContainer
|
|
||||||
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] = []
|
||||||
@@ -389,36 +379,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
if worldname not in non_apworlds:
|
if worldname not in non_apworlds:
|
||||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||||
world_directory = self.libfolder / "worlds" / file_name
|
world_directory = self.libfolder / "worlds" / file_name
|
||||||
if os.path.isfile(world_directory / "archipelago.json"):
|
|
||||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
|
||||||
manifest = json.load(manifest_file)
|
|
||||||
|
|
||||||
assert "game" in manifest, (
|
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
|
||||||
"does not define a \"game\"."
|
|
||||||
)
|
|
||||||
assert manifest["game"] == worldtype.game, (
|
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
|
|
||||||
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
manifest = {}
|
|
||||||
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
||||||
# which should be ok
|
# which should be ok
|
||||||
zip_path = self.libfolder / "worlds" / (file_name + ".apworld")
|
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
|
||||||
apworld = APWorldContainer(str(zip_path))
|
|
||||||
apworld.minimum_ap_version = version_tuple
|
|
||||||
apworld.maximum_ap_version = version_tuple
|
|
||||||
apworld.game = worldtype.game
|
|
||||||
manifest.update(apworld.get_manifest())
|
|
||||||
apworld.manifest_path = f"{file_name}/archipelago.json"
|
|
||||||
with zipfile.ZipFile(zip_path, "x", zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel=9) as zf:
|
compresslevel=9) as zf:
|
||||||
for path in world_directory.rglob("*.*"):
|
for path in world_directory.rglob("*.*"):
|
||||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
||||||
if not relative_path.endswith("archipelago.json"):
|
zf.write(path, relative_path)
|
||||||
zf.write(path, relative_path)
|
|
||||||
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
|
||||||
folders_to_remove.append(file_name)
|
folders_to_remove.append(file_name)
|
||||||
shutil.rmtree(world_directory)
|
shutil.rmtree(world_directory)
|
||||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||||
|
|||||||
@@ -9,7 +9,98 @@ from test.general import gen_steps
|
|||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.AutoWorld import World, call_all
|
from worlds.AutoWorld import World, call_all
|
||||||
|
|
||||||
from BaseClasses import Location, MultiWorld, CollectionState, Item
|
from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
|
||||||
|
from worlds.alttp.Items import item_factory
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(unittest.TestCase):
|
||||||
|
multiworld: MultiWorld
|
||||||
|
_state_cache = {}
|
||||||
|
|
||||||
|
def get_state(self, items):
|
||||||
|
if (self.multiworld, tuple(items)) in self._state_cache:
|
||||||
|
return self._state_cache[self.multiworld, tuple(items)]
|
||||||
|
state = CollectionState(self.multiworld)
|
||||||
|
for item in items:
|
||||||
|
item.classification = ItemClassification.progression
|
||||||
|
state.collect(item, prevent_sweep=True)
|
||||||
|
state.sweep_for_advancements()
|
||||||
|
state.update_reachable_regions(1)
|
||||||
|
self._state_cache[self.multiworld, tuple(items)] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_path(self, state, region):
|
||||||
|
def flist_to_iter(node):
|
||||||
|
while node:
|
||||||
|
value, node = node
|
||||||
|
yield value
|
||||||
|
|
||||||
|
from itertools import zip_longest
|
||||||
|
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||||
|
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||||
|
# Now we combine the flat string list into (region, exit) pairs
|
||||||
|
pathsiter = iter(string_path_flat)
|
||||||
|
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||||
|
return list(pathpairs)
|
||||||
|
|
||||||
|
def run_location_tests(self, access_pool):
|
||||||
|
for i, (location, access, *item_pool) in enumerate(access_pool):
|
||||||
|
items = item_pool[0]
|
||||||
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
|
state = self._get_items(item_pool, all_except)
|
||||||
|
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
||||||
|
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
||||||
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
|
||||||
|
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
||||||
|
|
||||||
|
# check for partial solution
|
||||||
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
|
for missing_item in item_pool[0]:
|
||||||
|
with self.subTest(msg="Location reachable without required item", location=location,
|
||||||
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
|
||||||
|
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
|
||||||
|
f"{missing_item} removed from: {item_pool}")
|
||||||
|
|
||||||
|
def run_entrance_tests(self, access_pool):
|
||||||
|
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
||||||
|
items = item_pool[0]
|
||||||
|
all_except = item_pool[1] if len(item_pool) > 1 else None
|
||||||
|
state = self._get_items(item_pool, all_except)
|
||||||
|
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
||||||
|
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
||||||
|
all_except=all_except, path=path, entry=i):
|
||||||
|
|
||||||
|
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
||||||
|
|
||||||
|
# check for partial solution
|
||||||
|
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
||||||
|
for missing_item in item_pool[0]:
|
||||||
|
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
||||||
|
items=item_pool[0], missing_item=missing_item, entry=i):
|
||||||
|
state = self._get_items_partial(item_pool, missing_item)
|
||||||
|
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
|
||||||
|
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
|
||||||
|
|
||||||
|
def _get_items(self, item_pool, all_except):
|
||||||
|
if all_except and len(all_except) > 0:
|
||||||
|
items = self.multiworld.itempool[:]
|
||||||
|
items = [item for item in items if
|
||||||
|
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
||||||
|
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
|
||||||
|
else:
|
||||||
|
items = item_factory(item_pool[0], self.multiworld.worlds[1])
|
||||||
|
return self.get_state(items)
|
||||||
|
|
||||||
|
def _get_items_partial(self, item_pool, missing_item):
|
||||||
|
new_items = item_pool[0].copy()
|
||||||
|
new_items.remove(missing_item)
|
||||||
|
items = item_factory(new_items, self.multiworld.worlds[1])
|
||||||
|
return self.get_state(items)
|
||||||
|
|
||||||
|
|
||||||
class WorldTestBase(unittest.TestCase):
|
class WorldTestBase(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
# based on python-websockets compression benchmark (c) Aymeric Augustin and contributors
|
|
||||||
# https://github.com/python-websockets/websockets/blob/main/experiments/compression/benchmark.py
|
|
||||||
|
|
||||||
import collections
|
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
|
|
||||||
REPEAT = 10
|
|
||||||
|
|
||||||
WB, ML = 12, 5 # defaults used as a reference
|
|
||||||
WBITS = range(9, 16)
|
|
||||||
MEMLEVELS = range(1, 10)
|
|
||||||
|
|
||||||
|
|
||||||
def benchmark(data: Iterable[bytes]) -> None:
|
|
||||||
size: dict[int, dict[int, float]] = collections.defaultdict(dict)
|
|
||||||
duration: dict[int, dict[int, float]] = collections.defaultdict(dict)
|
|
||||||
|
|
||||||
for wbits in WBITS:
|
|
||||||
for memLevel in MEMLEVELS:
|
|
||||||
encoder = zlib.compressobj(wbits=-wbits, memLevel=memLevel)
|
|
||||||
encoded = []
|
|
||||||
|
|
||||||
print(f"Compressing {REPEAT} times with {wbits=} and {memLevel=}")
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
for _ in range(REPEAT):
|
|
||||||
for item in data:
|
|
||||||
# Taken from PerMessageDeflate.encode
|
|
||||||
item = encoder.compress(item) + encoder.flush(zlib.Z_SYNC_FLUSH)
|
|
||||||
if item.endswith(b"\x00\x00\xff\xff"):
|
|
||||||
item = item[:-4]
|
|
||||||
encoded.append(item)
|
|
||||||
|
|
||||||
t1 = time.perf_counter()
|
|
||||||
|
|
||||||
size[wbits][memLevel] = sum(len(item) for item in encoded) / REPEAT
|
|
||||||
duration[wbits][memLevel] = (t1 - t0) / REPEAT
|
|
||||||
|
|
||||||
raw_size = sum(len(item) for item in data)
|
|
||||||
|
|
||||||
print("=" * 79)
|
|
||||||
print("Compression ratio")
|
|
||||||
print("=" * 79)
|
|
||||||
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
|
|
||||||
for wbits in WBITS:
|
|
||||||
print(
|
|
||||||
"\t".join(
|
|
||||||
[str(wbits)]
|
|
||||||
+ [
|
|
||||||
f"{100 * (1 - size[wbits][memLevel] / raw_size):.1f}%"
|
|
||||||
for memLevel in MEMLEVELS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("=" * 79)
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 79)
|
|
||||||
print("CPU time")
|
|
||||||
print("=" * 79)
|
|
||||||
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
|
|
||||||
for wbits in WBITS:
|
|
||||||
print(
|
|
||||||
"\t".join(
|
|
||||||
[str(wbits)]
|
|
||||||
+ [
|
|
||||||
f"{1000 * duration[wbits][memLevel]:.1f}ms"
|
|
||||||
for memLevel in MEMLEVELS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("=" * 79)
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 79)
|
|
||||||
print(f"Size vs. {WB} \\ {ML}")
|
|
||||||
print("=" * 79)
|
|
||||||
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
|
|
||||||
for wbits in WBITS:
|
|
||||||
print(
|
|
||||||
"\t".join(
|
|
||||||
[str(wbits)]
|
|
||||||
+ [
|
|
||||||
f"{100 * (size[wbits][memLevel] / size[WB][ML] - 1):.1f}%"
|
|
||||||
for memLevel in MEMLEVELS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("=" * 79)
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 79)
|
|
||||||
print(f"Time vs. {WB} \\ {ML}")
|
|
||||||
print("=" * 79)
|
|
||||||
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
|
|
||||||
for wbits in WBITS:
|
|
||||||
print(
|
|
||||||
"\t".join(
|
|
||||||
[str(wbits)]
|
|
||||||
+ [
|
|
||||||
f"{100 * (duration[wbits][memLevel] / duration[WB][ML] - 1):.1f}%"
|
|
||||||
for memLevel in MEMLEVELS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print("=" * 79)
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_data_package_corpus() -> list[bytes]:
|
|
||||||
# compared to default 12, 5:
|
|
||||||
# 11, 4 saves 16K RAM, gives +4.6% size, -5.0% time .. +1.1% time
|
|
||||||
# 10, 4 saves 20K RAM, gives +10.2% size, -3.8% time .. +0.6% time
|
|
||||||
# 11, 3 saves 20K RAM, gives +6.5% size, +14.2% time
|
|
||||||
# 10, 3 saves 24K RAM, gives +12.8% size, +0.5% time .. +6.9% time
|
|
||||||
# NOTE: time delta is highly unstable; time is ~100ms
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore")
|
|
||||||
|
|
||||||
from NetUtils import encode
|
|
||||||
from worlds import network_data_package
|
|
||||||
|
|
||||||
return [encode(network_data_package).encode("utf-8")]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_solo_release_corpus() -> list[bytes]:
|
|
||||||
# compared to default 12, 5:
|
|
||||||
# 11, 4 saves 16K RAM, gives +0.9% size, +3.9% time
|
|
||||||
# 10, 4 saves 20K RAM, gives +1.4% size, +3.4% time
|
|
||||||
# 11, 3 saves 20K RAM, gives +1.8% size, +13.9% time
|
|
||||||
# 10, 3 saves 24K RAM, gives +2.1% size, +4.8% time
|
|
||||||
# NOTE: time delta is highly unstable; time is ~0.4ms
|
|
||||||
|
|
||||||
from random import Random
|
|
||||||
from MultiServer import json_format_send_event
|
|
||||||
from NetUtils import encode, NetworkItem
|
|
||||||
|
|
||||||
r = Random()
|
|
||||||
r.seed(0)
|
|
||||||
solo_release = []
|
|
||||||
solo_release_locations = [r.randint(1000, 1999) for _ in range(200)]
|
|
||||||
solo_release_items = sorted([r.randint(1000, 1999) for _ in range(200)]) # currently sorted by item
|
|
||||||
solo_player = 1
|
|
||||||
for location, item in zip(solo_release_locations, solo_release_items):
|
|
||||||
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
|
|
||||||
network_item = NetworkItem(item, location, solo_player, flags)
|
|
||||||
solo_release.append(json_format_send_event(network_item, solo_player))
|
|
||||||
solo_release.append({
|
|
||||||
"cmd": "ReceivedItems",
|
|
||||||
"index": 0,
|
|
||||||
"items": solo_release_items,
|
|
||||||
})
|
|
||||||
solo_release.append({
|
|
||||||
"cmd": "RoomUpdate",
|
|
||||||
"hint_points": 200,
|
|
||||||
"checked_locations": solo_release_locations,
|
|
||||||
})
|
|
||||||
return [encode(solo_release).encode("utf-8")]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_gameplay_corpus() -> list[bytes]:
|
|
||||||
# compared to default 12, 5:
|
|
||||||
# 11, 4 saves 16K RAM, gives +13.6% size, +4.1% time
|
|
||||||
# 10, 4 saves 20K RAM, gives +22.3% size, +2.2% time
|
|
||||||
# 10, 3 saves 24K RAM, gives +26.2% size, +1.6% time
|
|
||||||
# NOTE: time delta is highly unstable; time is 4ms
|
|
||||||
|
|
||||||
from copy import copy
|
|
||||||
from random import Random
|
|
||||||
from MultiServer import json_format_send_event
|
|
||||||
from NetUtils import encode, NetworkItem
|
|
||||||
|
|
||||||
r = Random()
|
|
||||||
r.seed(0)
|
|
||||||
gameplay = []
|
|
||||||
observer = 1
|
|
||||||
hint_points = 0
|
|
||||||
index = 0
|
|
||||||
players = list(range(1, 10))
|
|
||||||
player_locations = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
|
|
||||||
player_items = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
|
|
||||||
player_receiver = {player: [r.randint(1, len(players)) for _ in range(200)] for player in players}
|
|
||||||
for i in range(0, len(player_locations[1])):
|
|
||||||
player_sequence = copy(players)
|
|
||||||
r.shuffle(player_sequence)
|
|
||||||
for finder in player_sequence:
|
|
||||||
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
|
|
||||||
receiver = player_receiver[finder][i]
|
|
||||||
item = player_items[finder][i]
|
|
||||||
location = player_locations[finder][i]
|
|
||||||
network_item = NetworkItem(item, location, receiver, flags)
|
|
||||||
gameplay.append(json_format_send_event(network_item, observer))
|
|
||||||
if finder == observer:
|
|
||||||
hint_points += 1
|
|
||||||
gameplay.append({
|
|
||||||
"cmd": "RoomUpdate",
|
|
||||||
"hint_points": hint_points,
|
|
||||||
"checked_locations": [location],
|
|
||||||
})
|
|
||||||
if receiver == observer:
|
|
||||||
gameplay.append({
|
|
||||||
"cmd": "ReceivedItems",
|
|
||||||
"index": index,
|
|
||||||
"items": [item],
|
|
||||||
})
|
|
||||||
index += 1
|
|
||||||
return [encode(gameplay).encode("utf-8")]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
#corpus = generate_data_package_corpus()
|
|
||||||
#corpus = generate_solo_release_corpus()
|
|
||||||
#corpus = generate_gameplay_corpus()
|
|
||||||
corpus = generate_data_package_corpus() + generate_solo_release_corpus() + generate_gameplay_corpus()
|
|
||||||
benchmark(corpus)
|
|
||||||
print(f"raw size: {sum(len(data) for data in corpus)}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
def run_locations_benchmark():
|
||||||
"""
|
|
||||||
Run a benchmark of location access rule performance against an empty_state and an all_state.
|
|
||||||
|
|
||||||
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
|
|
||||||
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
|
|
||||||
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
|
|
||||||
than running all iterations for the location rule being benchmarked.
|
|
||||||
"""
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import gc
|
import gc
|
||||||
@@ -42,8 +34,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
|||||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||||
|
|
||||||
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
||||||
if freeze_gc:
|
|
||||||
gc.freeze()
|
|
||||||
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
||||||
f"runs of {test_location}.access_rule({state_name})", logger) as t:
|
f"runs of {test_location}.access_rule({state_name})", logger) as t:
|
||||||
for _ in range(self.rule_iterations):
|
for _ in range(self.rule_iterations):
|
||||||
@@ -51,8 +41,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
|||||||
# if time is taken to disentangle complex ref chains,
|
# if time is taken to disentangle complex ref chains,
|
||||||
# this time should be attributed to the rule.
|
# this time should be attributed to the rule.
|
||||||
gc.collect()
|
gc.collect()
|
||||||
if freeze_gc:
|
|
||||||
gc.unfreeze()
|
|
||||||
return t.dif
|
return t.dif
|
||||||
|
|
||||||
def main(self):
|
def main(self):
|
||||||
@@ -76,13 +64,9 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
|||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
for step in self.gen_steps:
|
for step in self.gen_steps:
|
||||||
if freeze_gc:
|
|
||||||
gc.freeze()
|
|
||||||
with TimeIt(f"{game} step {step}", logger):
|
with TimeIt(f"{game} step {step}", logger):
|
||||||
call_all(multiworld, step)
|
call_all(multiworld, step)
|
||||||
gc.collect()
|
gc.collect()
|
||||||
if freeze_gc:
|
|
||||||
gc.unfreeze()
|
|
||||||
|
|
||||||
locations = sorted(multiworld.get_unfilled_locations())
|
locations = sorted(multiworld.get_unfilled_locations())
|
||||||
if not locations:
|
if not locations:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from typing import Any, List, Optional, Tuple, Type
|
from typing import List, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
@@ -31,8 +31,8 @@ def setup_solo_multiworld(
|
|||||||
return setup_multiworld(world_type, steps, seed)
|
return setup_multiworld(world_type, steps, seed)
|
||||||
|
|
||||||
|
|
||||||
def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str, ...] = gen_steps,
|
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
|
||||||
seed: int | None = None, options: dict[str, Any] | list[dict[str, Any]] = None) -> MultiWorld:
|
seed: Optional[int] = None) -> MultiWorld:
|
||||||
"""
|
"""
|
||||||
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
|
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
|
||||||
calling the provided gen steps.
|
calling the provided gen steps.
|
||||||
@@ -40,27 +40,20 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
|
|||||||
:param worlds: Type/s of worlds to generate a multiworld for
|
:param worlds: Type/s of worlds to generate a multiworld for
|
||||||
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
|
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
|
||||||
:param seed: The seed to be used when creating this multiworld
|
:param seed: The seed to be used when creating this multiworld
|
||||||
:param options: Options to set on each world. If just one dict of options is passed, it will be used for all worlds.
|
|
||||||
:return: The generated multiworld
|
:return: The generated multiworld
|
||||||
"""
|
"""
|
||||||
if not isinstance(worlds, list):
|
if not isinstance(worlds, list):
|
||||||
worlds = [worlds]
|
worlds = [worlds]
|
||||||
|
|
||||||
if options is None:
|
|
||||||
options = [{}] * len(worlds)
|
|
||||||
elif not isinstance(options, list):
|
|
||||||
options = [options] * len(worlds)
|
|
||||||
|
|
||||||
players = len(worlds)
|
players = len(worlds)
|
||||||
multiworld = MultiWorld(players)
|
multiworld = MultiWorld(players)
|
||||||
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
|
||||||
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
|
||||||
multiworld.set_seed(seed)
|
multiworld.set_seed(seed)
|
||||||
args = Namespace()
|
args = Namespace()
|
||||||
for player, (world_type, option_overrides) in enumerate(zip(worlds, options), 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():
|
||||||
updated_options = getattr(args, key, {})
|
updated_options = getattr(args, key, {})
|
||||||
updated_options[player] = option.from_any(option_overrides.get(key, 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)
|
multiworld.state = CollectionState(multiworld)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from Utils import get_intended_text, get_input_text_from_response
|
|||||||
class TestClient(unittest.TestCase):
|
class TestClient(unittest.TestCase):
|
||||||
def test_autofill_hint_from_fuzzy_hint(self) -> None:
|
def test_autofill_hint_from_fuzzy_hint(self) -> None:
|
||||||
tests = (
|
tests = (
|
||||||
("item", ["item1", "item2"]), # Multiple close matches
|
("item", ["item1", "item2"]), # Multiple close matches
|
||||||
("itm", ["item1", "item21"]), # No close match, multiple option
|
("itm", ["item1", "item21"]), # No close match, multiple option
|
||||||
("item", ["item1"]), # No close match, single option
|
("item", ["item1"]), # No close match, single option
|
||||||
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
|
("item", ["\"item\" 'item' (item)"]), # Testing different special characters
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ class TestClient(unittest.TestCase):
|
|||||||
item_name, usable, response = get_intended_text(input_text, possible_answers)
|
item_name, usable, response = get_intended_text(input_text, possible_answers)
|
||||||
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
|
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
|
||||||
|
|
||||||
hint_command = get_input_text_from_response(response, "!hint")
|
hint_command = get_input_text_from_response(response, "hint")
|
||||||
self.assertIsNotNone(hint_command,
|
self.assertIsNotNone(hint_command,
|
||||||
"The response to fuzzy hints is no longer recognized by the hint autofill")
|
"The response to fuzzy hints is no longer recognized by the hint autofill")
|
||||||
self.assertEqual(hint_command, f"!hint {item_name}",
|
self.assertEqual(hint_command, f"!hint {item_name}",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
from Options import ItemLinks, Choice
|
||||||
from Utils import restricted_dumps
|
from Utils import restricted_dumps
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
|
|||||||
for link in item_links.values():
|
for link in item_links.values():
|
||||||
self.assertEqual(link.value[0], item_link_group[0])
|
self.assertEqual(link.value[0], item_link_group[0])
|
||||||
|
|
||||||
def test_pickle_dumps_default(self):
|
def test_pickle_dumps(self):
|
||||||
"""Test that default option values can be pickled into database for WebHost generation"""
|
"""Test options can be pickled into database for WebHost generation"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden:
|
if not world_type.hidden:
|
||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
@@ -81,23 +81,3 @@ class TestOptions(unittest.TestCase):
|
|||||||
restricted_dumps(option.from_any(option.default))
|
restricted_dumps(option.from_any(option.default))
|
||||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||||
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||||
|
|
||||||
def test_pickle_dumps_plando(self):
|
|
||||||
"""Test that plando options using containers of a custom type can be pickled"""
|
|
||||||
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
|
|
||||||
class TestPlandoConnections(PlandoConnections):
|
|
||||||
entrances = {"An Entrance"}
|
|
||||||
exits = {"An Exit"}
|
|
||||||
plando_connection_value = PlandoConnections(
|
|
||||||
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
|
|
||||||
)
|
|
||||||
|
|
||||||
plando_values = {
|
|
||||||
"PlandoConnections": plando_connection_value,
|
|
||||||
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
|
|
||||||
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
|
|
||||||
}
|
|
||||||
|
|
||||||
for option_key, value in plando_values.items():
|
|
||||||
with self.subTest(option=option_key):
|
|
||||||
restricted_dumps(value)
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"""Check world sources' manifest files"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, ClassVar
|
|
||||||
|
|
||||||
import test
|
|
||||||
from Utils import home_path, local_path
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
|
||||||
from ..param import classvar_matrix
|
|
||||||
|
|
||||||
|
|
||||||
test_path = Path(test.__file__).parent
|
|
||||||
worlds_paths = [
|
|
||||||
Path(local_path("worlds")),
|
|
||||||
Path(local_path("custom_worlds")),
|
|
||||||
Path(home_path("worlds")),
|
|
||||||
Path(home_path("custom_worlds")),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
|
|
||||||
source_world_names = [
|
|
||||||
k
|
|
||||||
for k, v in AutoWorldRegister.world_types.items()
|
|
||||||
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_source_world_manifest_path(game: str) -> Path | None:
|
|
||||||
"""Get path of archipelago.json in the world's root folder from game name."""
|
|
||||||
# TODO: add a feature to AutoWorld that makes this less annoying
|
|
||||||
world_type = AutoWorldRegister.world_types[game]
|
|
||||||
world_type_path = Path(world_type.__file__)
|
|
||||||
for worlds_path in worlds_paths:
|
|
||||||
if world_type_path.is_relative_to(worlds_path):
|
|
||||||
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
|
|
||||||
manifest_path = world_root / "archipelago.json"
|
|
||||||
return manifest_path if manifest_path.exists() else None
|
|
||||||
assert False, f"{world_type_path} not found in any worlds path"
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: remove the filter once manifests are mandatory.
|
|
||||||
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
|
|
||||||
class TestWorldManifest(unittest.TestCase):
|
|
||||||
game: ClassVar[str]
|
|
||||||
manifest: ClassVar[dict[str, Any]]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
world_type = AutoWorldRegister.world_types[cls.game]
|
|
||||||
assert world_type.game == cls.game
|
|
||||||
manifest_path = get_source_world_manifest_path(cls.game)
|
|
||||||
assert manifest_path # make mypy happy
|
|
||||||
with manifest_path.open("r", encoding="utf-8") as f:
|
|
||||||
cls.manifest = json.load(f)
|
|
||||||
|
|
||||||
def test_game(self) -> None:
|
|
||||||
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
|
|
||||||
self.assertIn(
|
|
||||||
"game",
|
|
||||||
self.manifest,
|
|
||||||
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.manifest["game"],
|
|
||||||
self.game,
|
|
||||||
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_world_version(self) -> None:
|
|
||||||
"""Test that world_version matches the requirements in apworld specification.md"""
|
|
||||||
if "world_version" in self.manifest:
|
|
||||||
world_version: str = self.manifest["world_version"]
|
|
||||||
self.assertIsInstance(
|
|
||||||
world_version,
|
|
||||||
str,
|
|
||||||
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
|
|
||||||
)
|
|
||||||
parts = world_version.split(".")
|
|
||||||
self.assertEqual(
|
|
||||||
len(parts),
|
|
||||||
3,
|
|
||||||
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
|
|
||||||
)
|
|
||||||
for part in parts:
|
|
||||||
self.assertTrue(
|
|
||||||
part.isdigit(),
|
|
||||||
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_container_version(self) -> None:
|
|
||||||
self.assertNotIn(
|
|
||||||
"version",
|
|
||||||
self.manifest,
|
|
||||||
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
|
|
||||||
)
|
|
||||||
self.assertNotIn(
|
|
||||||
"compatible_version",
|
|
||||||
self.manifest,
|
|
||||||
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
|
|
||||||
)
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
# Run with `python test/hosting` instead,
|
# Run with `python test/hosting` instead,
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,7 +11,7 @@ from test.hosting.client import Client
|
|||||||
from test.hosting.generate import generate_local
|
from test.hosting.generate import generate_local
|
||||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
||||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
||||||
stop_autogen, stop_autohost, upload_multidata, generate_remote)
|
stop_autohost, upload_multidata)
|
||||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
from test.hosting.world import copy as copy_world, delete as delete_world
|
||||||
|
|
||||||
failure = False
|
failure = False
|
||||||
@@ -57,62 +56,35 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
warnings.simplefilter("ignore", ResourceWarning)
|
warnings.simplefilter("ignore", ResourceWarning)
|
||||||
warnings.simplefilter("ignore", UserWarning)
|
warnings.simplefilter("ignore", UserWarning)
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
|
|
||||||
spacer = '=' * 80
|
spacer = '=' * 80
|
||||||
|
|
||||||
with TemporaryDirectory() as tempdir:
|
with TemporaryDirectory() as tempdir:
|
||||||
empty_file = str(Path(tempdir) / "empty")
|
|
||||||
open(empty_file, "w").close()
|
|
||||||
sys.argv += ["--config_override", empty_file] # tests #5541
|
|
||||||
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
||||||
p1_games: list[str] = []
|
p1_games = []
|
||||||
data_paths: list[Path | None] = []
|
data_paths = []
|
||||||
rooms: list[str] = []
|
rooms = []
|
||||||
multidata: Path | None
|
|
||||||
|
|
||||||
copy_world("VVVVVV", "Temp World")
|
copy_world("VVVVVV", "Temp World")
|
||||||
try:
|
try:
|
||||||
for n, games in enumerate(multis, 1):
|
for n, games in enumerate(multis, 1):
|
||||||
print(f"Generating [{n}] {', '.join(games)} offline")
|
print(f"Generating [{n}] {', '.join(games)}")
|
||||||
multidata = generate_local(games, tempdir)
|
multidata = generate_local(games, tempdir)
|
||||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||||
data_paths.append(multidata)
|
|
||||||
p1_games.append(games[0])
|
p1_games.append(games[0])
|
||||||
|
data_paths.append(multidata)
|
||||||
finally:
|
finally:
|
||||||
delete_world("Temp World")
|
delete_world("Temp World")
|
||||||
|
|
||||||
webapp = get_app(tempdir)
|
webapp = get_app(tempdir)
|
||||||
webhost_client = webapp.test_client()
|
webhost_client = webapp.test_client()
|
||||||
|
|
||||||
for n, multidata in enumerate(data_paths, 1):
|
for n, multidata in enumerate(data_paths, 1):
|
||||||
assert multidata
|
|
||||||
seed = upload_multidata(webhost_client, multidata)
|
seed = upload_multidata(webhost_client, multidata)
|
||||||
print(f"Uploaded [{n}] {multidata} as {seed}\n")
|
|
||||||
room = create_room(webhost_client, seed)
|
room = create_room(webhost_client, seed)
|
||||||
print(f"Started [{n}] {seed} as {room}\n")
|
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
||||||
rooms.append(room)
|
|
||||||
|
|
||||||
# Generate 1 extra game on WebHost
|
|
||||||
from WebHostLib.autolauncher import autogen
|
|
||||||
for n, games in enumerate(multis[:1], len(multis) + 1):
|
|
||||||
multis.append(games)
|
|
||||||
try:
|
|
||||||
print(f"Generating [{n}] {', '.join(games)} online")
|
|
||||||
autogen(webapp.config)
|
|
||||||
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
|
|
||||||
seed = generate_remote(webhost_client, games)
|
|
||||||
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
|
|
||||||
finally:
|
|
||||||
stop_autogen()
|
|
||||||
data_paths.append(None) # WebHost-only
|
|
||||||
room = create_room(webhost_client, seed)
|
|
||||||
print(f"Started [{n}] {seed} as {room}\n")
|
|
||||||
rooms.append(room)
|
rooms.append(room)
|
||||||
|
|
||||||
print("Starting autohost")
|
print("Starting autohost")
|
||||||
@@ -124,10 +96,31 @@ if __name__ == "__main__":
|
|||||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
||||||
involved_games = {"Archipelago"} | set(multi_games)
|
involved_games = {"Archipelago"} | set(multi_games)
|
||||||
for collected_items in range(3):
|
for collected_items in range(3):
|
||||||
|
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||||
|
with LocalServeGame(multidata) as host:
|
||||||
|
with Client(host.address, game, "Player1") as client:
|
||||||
|
local_data_packages = client.games_packages
|
||||||
|
local_collected_items = len(client.checked_locations)
|
||||||
|
if collected_items < 2: # Don't collect anything on the last iteration
|
||||||
|
client.collect_any()
|
||||||
|
# TODO: Ctrl+C test here as well
|
||||||
|
|
||||||
|
for game_name in sorted(involved_games):
|
||||||
|
expect_true(game_name in local_data_packages,
|
||||||
|
f"{game_name} missing from MultiServer datap ackage")
|
||||||
|
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||||
|
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||||
|
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||||
|
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||||
|
for game_name in local_data_packages:
|
||||||
|
expect_true(game_name in involved_games,
|
||||||
|
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||||
|
assert_equal(local_collected_items, collected_items,
|
||||||
|
"MultiServer did not load or save correctly")
|
||||||
|
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
||||||
prev_host_adr: str
|
prev_host_adr: str
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
with WebHostServeGame(webhost_client, room) as host:
|
||||||
sleep(.1) # wait for the server to fully start before doing anything
|
|
||||||
prev_host_adr = host.address
|
prev_host_adr = host.address
|
||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
web_data_packages = client.games_packages
|
web_data_packages = client.games_packages
|
||||||
@@ -141,7 +134,6 @@ if __name__ == "__main__":
|
|||||||
autohost(webapp.config) # this will spin the room right up again
|
autohost(webapp.config) # this will spin the room right up again
|
||||||
sleep(1) # make log less annoying
|
sleep(1) # make log less annoying
|
||||||
# if saving failed, the next iteration will fail below
|
# if saving failed, the next iteration will fail below
|
||||||
sleep(2) # work around issue #5571
|
|
||||||
|
|
||||||
# verify server shut down
|
# verify server shut down
|
||||||
try:
|
try:
|
||||||
@@ -164,31 +156,6 @@ if __name__ == "__main__":
|
|||||||
"customserver did not load or save correctly during/after "
|
"customserver did not load or save correctly during/after "
|
||||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
||||||
|
|
||||||
if not multidata:
|
|
||||||
continue # games rolled on WebHost can not be tested against MultiServer
|
|
||||||
|
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
|
||||||
with LocalServeGame(multidata) as host:
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
|
||||||
local_data_packages = client.games_packages
|
|
||||||
local_collected_items = len(client.checked_locations)
|
|
||||||
if collected_items < 2: # Don't collect anything on the last iteration
|
|
||||||
client.collect_any()
|
|
||||||
# TODO: Ctrl+C test here as well
|
|
||||||
|
|
||||||
for game_name in sorted(involved_games):
|
|
||||||
expect_true(game_name in local_data_packages,
|
|
||||||
f"{game_name} missing from MultiServer datapackage")
|
|
||||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
for game_name in local_data_packages:
|
|
||||||
expect_true(game_name in involved_games,
|
|
||||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
|
||||||
assert_equal(local_collected_items, collected_items,
|
|
||||||
"MultiServer did not load or save correctly")
|
|
||||||
|
|
||||||
# compare customserver to MultiServer
|
# compare customserver to MultiServer
|
||||||
expect_equal(local_data_packages, web_data_packages,
|
expect_equal(local_data_packages, web_data_packages,
|
||||||
"customserver datapackage differs from MultiServer")
|
"customserver datapackage differs from MultiServer")
|
||||||
@@ -209,12 +176,10 @@ if __name__ == "__main__":
|
|||||||
print(f"Restoring multidata for {room}")
|
print(f"Restoring multidata for {room}")
|
||||||
set_multidata_for_room(webhost_client, room, old_data)
|
set_multidata_for_room(webhost_client, room, old_data)
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
with WebHostServeGame(webhost_client, room) as host:
|
||||||
sleep(.1) # wait for the server to fully start before doing anything
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
assert_equal(len(client.checked_locations), 2,
|
assert_equal(len(client.checked_locations), 2,
|
||||||
"Save was destroyed during exception in customserver")
|
"Save was destroyed during exception in customserver")
|
||||||
print("Save file is not busted 🥳")
|
print("Save file is not busted 🥳")
|
||||||
sleep(2) # work around issue #5571
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
print("Stopping autohost")
|
print("Stopping autohost")
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import io
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
|
|
||||||
from WebHostLib import to_python
|
from WebHostLib import to_python
|
||||||
|
|
||||||
@@ -14,7 +10,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_app",
|
"get_app",
|
||||||
"generate_remote",
|
|
||||||
"upload_multidata",
|
"upload_multidata",
|
||||||
"create_room",
|
"create_room",
|
||||||
"start_room",
|
"start_room",
|
||||||
@@ -22,7 +17,6 @@ __all__ = [
|
|||||||
"set_room_timeout",
|
"set_room_timeout",
|
||||||
"get_multidata_for_room",
|
"get_multidata_for_room",
|
||||||
"set_multidata_for_room",
|
"set_multidata_for_room",
|
||||||
"stop_autogen",
|
|
||||||
"stop_autohost",
|
"stop_autohost",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -39,43 +33,10 @@ def get_app(tempdir: str) -> "Flask":
|
|||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"HOST_ADDRESS": "localhost",
|
"HOST_ADDRESS": "localhost",
|
||||||
"HOSTERS": 1,
|
"HOSTERS": 1,
|
||||||
"GENERATORS": 1,
|
|
||||||
"JOB_THRESHOLD": 1,
|
|
||||||
})
|
})
|
||||||
return get_app()
|
return get_app()
|
||||||
|
|
||||||
|
|
||||||
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
|
|
||||||
data = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
|
||||||
for n, game in enumerate(games, 1):
|
|
||||||
name = f"{n}.yaml"
|
|
||||||
zip_file.writestr(name, json.dumps({
|
|
||||||
"name": f"Player{n}",
|
|
||||||
"game": game,
|
|
||||||
game: {},
|
|
||||||
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
|
|
||||||
}))
|
|
||||||
data.seek(0)
|
|
||||||
response = app_client.post("/generate", content_type="multipart/form-data", data={
|
|
||||||
"file": (data, "yamls.zip"),
|
|
||||||
})
|
|
||||||
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
|
|
||||||
assert "Location" in response.headers, f"Starting gen failed: no redirect"
|
|
||||||
location = response.headers["Location"]
|
|
||||||
assert isinstance(location, str)
|
|
||||||
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
|
|
||||||
for attempt in range(10):
|
|
||||||
response = app_client.get(location)
|
|
||||||
if "Location" in response.headers:
|
|
||||||
location = response.headers["Location"]
|
|
||||||
assert isinstance(location, str)
|
|
||||||
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
|
|
||||||
return location[6:]
|
|
||||||
time.sleep(1)
|
|
||||||
raise TimeoutError("WebHost gen did not finish")
|
|
||||||
|
|
||||||
|
|
||||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
||||||
response = app_client.post("/uploads", data={
|
response = app_client.post("/uploads", data={
|
||||||
"file": multidata.open("rb"),
|
"file": multidata.open("rb"),
|
||||||
@@ -227,7 +188,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
|
|||||||
room.seed.multidata = data
|
room.seed.multidata = data
|
||||||
|
|
||||||
|
|
||||||
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
|
def stop_autohost(graceful: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
@@ -237,30 +198,13 @@ def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
|
|||||||
|
|
||||||
stop()
|
stop()
|
||||||
proc: multiprocessing.process.BaseProcess
|
proc: multiprocessing.process.BaseProcess
|
||||||
for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
|
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
||||||
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
|
|
||||||
# and ungraceful may not save the game
|
|
||||||
if proc.pid == os.getpid():
|
|
||||||
continue
|
|
||||||
if graceful and proc.pid:
|
if graceful and proc.pid:
|
||||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||||
else:
|
else:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
try:
|
try:
|
||||||
try:
|
proc.join(30)
|
||||||
proc.join(30)
|
|
||||||
except TimeoutError:
|
|
||||||
raise
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
|
|
||||||
proc.join(30)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.join()
|
proc.join()
|
||||||
|
|
||||||
def stop_autogen(graceful: bool = True) -> None:
|
|
||||||
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
|
|
||||||
_stop_webhost_mp("SpawnPoolWorker-", graceful)
|
|
||||||
|
|
||||||
def stop_autohost(graceful: bool = True) -> None:
|
|
||||||
_stop_webhost_mp("MultiHoster", graceful)
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
|
|||||||
|
|
||||||
def copy(src: str, dst: str) -> None:
|
def copy(src: str, dst: str) -> None:
|
||||||
from Utils import get_file_safe_name
|
from Utils import get_file_safe_name
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
assert dst not in _new_worlds, "World already created"
|
assert dst not in _new_worlds, "World already created"
|
||||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from Utils import DaemonThreadPoolExecutor
|
|
||||||
|
|
||||||
|
|
||||||
class DaemonThreadPoolExecutorTest(unittest.TestCase):
|
|
||||||
def test_is_daemon(self) -> None:
|
|
||||||
def run() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
with DaemonThreadPoolExecutor(1) as executor:
|
|
||||||
executor.submit(run)
|
|
||||||
|
|
||||||
self.assertTrue(next(iter(executor._threads)).daemon)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import os
|
|
||||||
import unittest
|
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
|
|
||||||
from mistune import HTMLRenderer, Markdown
|
|
||||||
|
|
||||||
from WebHostLib.markdown import ImgUrlRewriteInlineParser, render_markdown
|
|
||||||
|
|
||||||
|
|
||||||
class ImgUrlRewriteTest(unittest.TestCase):
|
|
||||||
markdown: Markdown
|
|
||||||
base_url = "/static/generated/docs/some_game"
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.markdown = Markdown(
|
|
||||||
renderer=HTMLRenderer(escape=False),
|
|
||||||
inline=ImgUrlRewriteInlineParser(self.base_url),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_relative_img_rewrite(self) -> None:
|
|
||||||
html = self.markdown("")
|
|
||||||
self.assertIn(f'src="{self.base_url}/image.png"', html)
|
|
||||||
|
|
||||||
def test_absolute_img_no_rewrite(self) -> None:
|
|
||||||
html = self.markdown("")
|
|
||||||
self.assertIn(f'src="/image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
|
|
||||||
def test_remote_img_no_rewrite(self) -> None:
|
|
||||||
html = self.markdown("")
|
|
||||||
self.assertIn(f'src="https://example.com/image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
|
|
||||||
def test_relative_link_no_rewrite(self) -> None:
|
|
||||||
# The parser is only supposed to update images, not links.
|
|
||||||
html = self.markdown("[Link](image.png)")
|
|
||||||
self.assertIn(f'href="image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
|
|
||||||
def test_absolute_link_no_rewrite(self) -> None:
|
|
||||||
html = self.markdown("[Link](/image.png)")
|
|
||||||
self.assertIn(f'href="/image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
|
|
||||||
def test_auto_link_no_rewrite(self) -> None:
|
|
||||||
html = self.markdown("<https://example.com/image.png>")
|
|
||||||
self.assertIn(f'href="https://example.com/image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
|
|
||||||
def test_relative_img_to_other_game(self) -> None:
|
|
||||||
html = self.markdown("")
|
|
||||||
self.assertIn(f'src="{self.base_url}/../Archipelago/image.png"', html)
|
|
||||||
|
|
||||||
|
|
||||||
class RenderMarkdownTest(unittest.TestCase):
|
|
||||||
"""Tests that render_markdown does the right thing."""
|
|
||||||
base_url = "/static/generated/docs/some_game"
|
|
||||||
|
|
||||||
def test_relative_img_rewrite(self) -> None:
|
|
||||||
f = NamedTemporaryFile(delete=False)
|
|
||||||
try:
|
|
||||||
f.write("".encode("utf-8"))
|
|
||||||
f.close()
|
|
||||||
html = render_markdown(f.name, self.base_url)
|
|
||||||
self.assertIn(f'src="{self.base_url}/image.png"', html)
|
|
||||||
finally:
|
|
||||||
os.unlink(f.name)
|
|
||||||
|
|
||||||
def test_no_img_rewrite(self) -> None:
|
|
||||||
f = NamedTemporaryFile(delete=False)
|
|
||||||
try:
|
|
||||||
f.write("".encode("utf-8"))
|
|
||||||
f.close()
|
|
||||||
html = render_markdown(f.name)
|
|
||||||
self.assertIn(f'src="image.png"', html)
|
|
||||||
self.assertNotIn(self.base_url, html)
|
|
||||||
finally:
|
|
||||||
os.unlink(f.name)
|
|
||||||
@@ -93,13 +93,3 @@ class TestTracker(TestBase):
|
|||||||
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
|
headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00"}, # missing timezone
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_tracker_api(self) -> None:
|
|
||||||
"""Verify that tracker api gives a reply for the room."""
|
|
||||||
with self.app.test_request_context():
|
|
||||||
with self.client.open(url_for("api.tracker_data", tracker=self.tracker_uuid)) as response:
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
with self.client.open(url_for("api.static_tracker_data", tracker=self.tracker_uuid)) as response:
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
with self.client.open(url_for("api.tracker_slot_data", tracker=self.tracker_uuid)) as response:
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
|
|||||||
|
|
||||||
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState
|
||||||
from Utils import Version
|
from Utils import deprecate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
|
||||||
@@ -75,10 +75,6 @@ class AutoWorldRegister(type):
|
|||||||
if "required_client_version" in base.__dict__:
|
if "required_client_version" in base.__dict__:
|
||||||
dct["required_client_version"] = max(dct["required_client_version"],
|
dct["required_client_version"] = max(dct["required_client_version"],
|
||||||
base.__dict__["required_client_version"])
|
base.__dict__["required_client_version"])
|
||||||
if "world_version" in dct:
|
|
||||||
if dct["world_version"] != Version(0, 0, 0):
|
|
||||||
raise RuntimeError(f"{name} is attempting to set 'world_version' from within the class. world_version "
|
|
||||||
f"can only be set from manifest.")
|
|
||||||
|
|
||||||
# construct class
|
# construct class
|
||||||
new_class = super().__new__(mcs, name, bases, dct)
|
new_class = super().__new__(mcs, name, bases, dct)
|
||||||
@@ -341,8 +337,6 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
"""If loaded from a .apworld, this is the Path to it."""
|
"""If loaded from a .apworld, this is the Path to it."""
|
||||||
__file__: ClassVar[str]
|
__file__: ClassVar[str]
|
||||||
"""path it was loaded from"""
|
"""path it was loaded from"""
|
||||||
world_version: ClassVar[Version] = Version(0, 0, 0)
|
|
||||||
"""Optional world version loaded from archipelago.json"""
|
|
||||||
|
|
||||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||||
assert multiworld is not None
|
assert multiworld is not None
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from typing import (ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence,
|
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
|
||||||
TYPE_CHECKING)
|
|
||||||
|
|
||||||
import bsdiff4
|
import bsdiff4
|
||||||
|
|
||||||
@@ -17,9 +16,6 @@ semaphore = threading.Semaphore(os.cpu_count() or 4)
|
|||||||
|
|
||||||
del threading
|
del threading
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from Utils import Version
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(abc.ABCMeta):
|
class AutoPatchRegister(abc.ABCMeta):
|
||||||
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
|
||||||
@@ -69,7 +65,7 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
|
|||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
container_version: int = 7
|
container_version: int = 6
|
||||||
|
|
||||||
|
|
||||||
def is_ap_player_container(game: str, data: bytes, player: int):
|
def is_ap_player_container(game: str, data: bytes, player: int):
|
||||||
@@ -96,7 +92,7 @@ class APContainer:
|
|||||||
version: ClassVar[int] = container_version
|
version: ClassVar[int] = container_version
|
||||||
compression_level: ClassVar[int] = 9
|
compression_level: ClassVar[int] = 9
|
||||||
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
|
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
|
||||||
manifest_path: str = "archipelago.json"
|
|
||||||
path: Optional[str]
|
path: Optional[str]
|
||||||
|
|
||||||
def __init__(self, path: Optional[str] = None):
|
def __init__(self, path: Optional[str] = None):
|
||||||
@@ -120,7 +116,7 @@ class APContainer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
||||||
else:
|
else:
|
||||||
opened_zipfile.writestr(self.manifest_path, manifest_str)
|
opened_zipfile.writestr("archipelago.json", manifest_str)
|
||||||
|
|
||||||
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
def read(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
|
||||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
||||||
@@ -141,18 +137,7 @@ class APContainer:
|
|||||||
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
|
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||||
try:
|
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||||
assert self.manifest_path.endswith("archipelago.json"), "Filename should be archipelago.json"
|
|
||||||
manifest_info = opened_zipfile.getinfo(self.manifest_path)
|
|
||||||
except KeyError as e:
|
|
||||||
for info in opened_zipfile.infolist():
|
|
||||||
if info.filename.endswith("archipelago.json"):
|
|
||||||
manifest_info = info
|
|
||||||
self.manifest_path = info.filename
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
with opened_zipfile.open(manifest_info, "r") as f:
|
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
if manifest["compatible_version"] > self.version:
|
if manifest["compatible_version"] > self.version:
|
||||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
||||||
@@ -167,33 +152,6 @@ class APContainer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class APWorldContainer(APContainer):
|
|
||||||
"""A zipfile containing a world implementation."""
|
|
||||||
game: str | None = None
|
|
||||||
world_version: "Version | None" = None
|
|
||||||
minimum_ap_version: "Version | None" = None
|
|
||||||
maximum_ap_version: "Version | None" = None
|
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
|
||||||
from Utils import tuplize_version
|
|
||||||
manifest = super().read_contents(opened_zipfile)
|
|
||||||
self.game = manifest["game"]
|
|
||||||
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
|
|
||||||
if version_key in manifest:
|
|
||||||
setattr(self, version_key, tuplize_version(manifest[version_key]))
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
def get_manifest(self) -> Dict[str, Any]:
|
|
||||||
manifest = super().get_manifest()
|
|
||||||
manifest["game"] = self.game
|
|
||||||
manifest["compatible_version"] = 7
|
|
||||||
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
|
|
||||||
version = getattr(self, version_key)
|
|
||||||
if version:
|
|
||||||
manifest[version_key] = version.as_simple_string()
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
class APPlayerContainer(APContainer):
|
class APPlayerContainer(APContainer):
|
||||||
"""A zipfile containing at least archipelago.json meant for a player"""
|
"""A zipfile containing at least archipelago.json meant for a player"""
|
||||||
game: ClassVar[Optional[str]] = None
|
game: ClassVar[Optional[str]] = None
|
||||||
@@ -290,8 +248,10 @@ class APProcedurePatch(APAutoPatchInterface):
|
|||||||
manifest["compatible_version"] = 5
|
manifest["compatible_version"] = 5
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||||
manifest = super(APProcedurePatch, self).read_contents(opened_zipfile)
|
super(APProcedurePatch, self).read_contents(opened_zipfile)
|
||||||
|
with opened_zipfile.open("archipelago.json", "r") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
if "procedure" not in manifest:
|
if "procedure" not in manifest:
|
||||||
# support patching files made before moving to procedures
|
# support patching files made before moving to procedures
|
||||||
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
|
self.procedure = [("apply_bsdiff4", ["delta.bsdiff4"])]
|
||||||
@@ -300,7 +260,6 @@ class APProcedurePatch(APAutoPatchInterface):
|
|||||||
for file in opened_zipfile.namelist():
|
for file in opened_zipfile.namelist():
|
||||||
if file not in ["archipelago.json"]:
|
if file not in ["archipelago.json"]:
|
||||||
self.files[file] = opened_zipfile.read(file)
|
self.files[file] = opened_zipfile.read(file)
|
||||||
return manifest
|
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||||
super(APProcedurePatch, self).write_contents(opened_zipfile)
|
super(APProcedurePatch, self).write_contents(opened_zipfile)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import weakref
|
|||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Optional, Callable, List, Iterable, Tuple
|
from typing import Optional, Callable, List, Iterable, Tuple
|
||||||
|
|
||||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running
|
from Utils import local_path, open_filename
|
||||||
|
|
||||||
|
|
||||||
class Type(Enum):
|
class Type(Enum):
|
||||||
@@ -177,10 +177,11 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
|
|||||||
if module_name == loaded_name:
|
if module_name == loaded_name:
|
||||||
found_already_loaded = True
|
found_already_loaded = True
|
||||||
break
|
break
|
||||||
if found_already_loaded and is_kivy_running():
|
if found_already_loaded:
|
||||||
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, "
|
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
|
||||||
"so a Launcher restart is required to use the new installation.")
|
"so a Launcher restart is required to use the new installation.\n"
|
||||||
world_source = worlds.WorldSource(str(target), is_zip=True, relative=False)
|
"If the Launcher is not open, no action needs to be taken.")
|
||||||
|
world_source = worlds.WorldSource(str(target), is_zip=True)
|
||||||
bisect.insort(worlds.world_sources, world_source)
|
bisect.insort(worlds.world_sources, world_source)
|
||||||
world_source.load()
|
world_source.load()
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ def install_apworld(apworld_path: str = "") -> None:
|
|||||||
source, target = res
|
source, target = res
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import Utils
|
import Utils
|
||||||
Utils.messagebox("Notice", str(e), error=True)
|
Utils.messagebox(e.__class__.__name__, str(e), error=True)
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
else:
|
else:
|
||||||
import Utils
|
import Utils
|
||||||
@@ -217,6 +218,8 @@ components: List[Component] = [
|
|||||||
description="Install an APWorld to play games not included with Archipelago by default."),
|
description="Install an APWorld to play games not included with Archipelago by default."),
|
||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
||||||
description="Connect to a multiworld using the text client."),
|
description="Connect to a multiworld using the text client."),
|
||||||
|
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
||||||
|
file_identifier=SuffixIdentifier('.apladx')),
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
Component('OoT Client', 'OoTClient',
|
Component('OoT Client', 'OoTClient',
|
||||||
@@ -240,67 +243,3 @@ icon_paths = {
|
|||||||
'icon': local_path('data', 'icon.png'),
|
'icon': local_path('data', 'icon.png'),
|
||||||
'discord': local_path('data', 'discord-mark-blue.png'),
|
'discord': local_path('data', 'discord-mark-blue.png'),
|
||||||
}
|
}
|
||||||
|
|
||||||
if not is_frozen():
|
|
||||||
def _build_apworlds(*launch_args: str):
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
from worlds.Files import APWorldContainer
|
|
||||||
from Launcher import open_folder
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
parser = argparse.ArgumentParser("Build script for APWorlds")
|
|
||||||
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
|
|
||||||
args = parser.parse_args(launch_args)
|
|
||||||
|
|
||||||
if args.worlds:
|
|
||||||
games = [(game, AutoWorldRegister.world_types.get(game, None)) for game in args.worlds]
|
|
||||||
else:
|
|
||||||
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
|
|
||||||
if not worldtype.zip_path]
|
|
||||||
|
|
||||||
apworlds_folder = os.path.join("build", "apworlds")
|
|
||||||
os.makedirs(apworlds_folder, exist_ok=True)
|
|
||||||
for worldname, worldtype in games:
|
|
||||||
if not worldtype:
|
|
||||||
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
|
|
||||||
continue
|
|
||||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
|
||||||
world_directory = os.path.join("worlds", file_name)
|
|
||||||
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
|
|
||||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
|
||||||
manifest = json.load(manifest_file)
|
|
||||||
|
|
||||||
assert "game" in manifest, (
|
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
|
||||||
"does not define a \"game\"."
|
|
||||||
)
|
|
||||||
assert manifest["game"] == worldtype.game, (
|
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
|
|
||||||
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
manifest = {}
|
|
||||||
|
|
||||||
zip_path = os.path.join(apworlds_folder, file_name + ".apworld")
|
|
||||||
apworld = APWorldContainer(str(zip_path))
|
|
||||||
apworld.game = worldtype.game
|
|
||||||
manifest.update(apworld.get_manifest())
|
|
||||||
apworld.manifest_path = f"{file_name}/archipelago.json"
|
|
||||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel=9) as zf:
|
|
||||||
for path in pathlib.Path(world_directory).rglob("*"):
|
|
||||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
|
|
||||||
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
|
|
||||||
continue
|
|
||||||
if not relative_path.endswith("archipelago.json"):
|
|
||||||
zf.write(path, relative_path)
|
|
||||||
zf.writestr(apworld.manifest_path, json.dumps(manifest))
|
|
||||||
open_folder(apworlds_folder)
|
|
||||||
|
|
||||||
|
|
||||||
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
|
|
||||||
description="Build APWorlds from loose-file world folders."))
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import warnings
|
|||||||
import zipimport
|
import zipimport
|
||||||
import time
|
import time
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from NetUtils import DataPackage
|
from NetUtils import DataPackage
|
||||||
from Utils import local_path, user_path, Version, version_tuple, tuplize_version
|
from Utils import local_path, user_path
|
||||||
|
|
||||||
local_folder = os.path.dirname(__file__)
|
local_folder = os.path.dirname(__file__)
|
||||||
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
|
||||||
@@ -39,7 +38,6 @@ class WorldSource:
|
|||||||
is_zip: bool = False
|
is_zip: bool = False
|
||||||
relative: bool = True # relative to regular world import folder
|
relative: bool = True # relative to regular world import folder
|
||||||
time_taken: float = -1.0
|
time_taken: float = -1.0
|
||||||
version: Version = Version(0, 0, 0)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
||||||
@@ -104,94 +102,12 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
|
|||||||
|
|
||||||
# import all submodules to trigger AutoWorldRegister
|
# import all submodules to trigger AutoWorldRegister
|
||||||
world_sources.sort()
|
world_sources.sort()
|
||||||
apworlds: list[WorldSource] = []
|
|
||||||
for world_source in world_sources:
|
for world_source in world_sources:
|
||||||
# load all loose files first:
|
world_source.load()
|
||||||
if world_source.is_zip:
|
|
||||||
apworlds.append(world_source)
|
|
||||||
else:
|
|
||||||
world_source.load()
|
|
||||||
|
|
||||||
|
|
||||||
from .AutoWorld import AutoWorldRegister
|
|
||||||
|
|
||||||
for world_source in world_sources:
|
|
||||||
if not world_source.is_zip:
|
|
||||||
# look for manifest
|
|
||||||
manifest = {}
|
|
||||||
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
|
|
||||||
for file in filenames:
|
|
||||||
if file.endswith("archipelago.json"):
|
|
||||||
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
|
|
||||||
manifest = json.load(manifest_file)
|
|
||||||
break
|
|
||||||
if manifest:
|
|
||||||
break
|
|
||||||
game = manifest.get("game")
|
|
||||||
if game in AutoWorldRegister.world_types:
|
|
||||||
AutoWorldRegister.world_types[game].world_version = tuplize_version(manifest.get("world_version", "0.0.0"))
|
|
||||||
|
|
||||||
if apworlds:
|
|
||||||
# encapsulation for namespace / gc purposes
|
|
||||||
def load_apworlds() -> None:
|
|
||||||
global apworlds
|
|
||||||
from .Files import APWorldContainer, InvalidDataError
|
|
||||||
core_compatible: list[tuple[WorldSource, APWorldContainer]] = []
|
|
||||||
|
|
||||||
def fail_world(game_name: str, reason: str, add_as_failed_to_load: bool = True) -> None:
|
|
||||||
if add_as_failed_to_load:
|
|
||||||
failed_world_loads.append(game_name)
|
|
||||||
logging.warning(reason)
|
|
||||||
|
|
||||||
for apworld_source in apworlds:
|
|
||||||
apworld: APWorldContainer = APWorldContainer(apworld_source.resolved_path)
|
|
||||||
# populate metadata
|
|
||||||
try:
|
|
||||||
apworld.read()
|
|
||||||
except InvalidDataError as e:
|
|
||||||
if version_tuple < (0, 7, 0):
|
|
||||||
logging.error(
|
|
||||||
f"Invalid or missing manifest file for {apworld_source.resolved_path}. "
|
|
||||||
"This apworld will stop working with Archipelago 0.7.0."
|
|
||||||
)
|
|
||||||
logging.error(e)
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
if apworld.minimum_ap_version and apworld.minimum_ap_version > version_tuple:
|
|
||||||
fail_world(apworld.game,
|
|
||||||
f"Did not load {apworld_source.path} "
|
|
||||||
f"as its minimum core version {apworld.minimum_ap_version} "
|
|
||||||
f"is higher than current core version {version_tuple}.")
|
|
||||||
elif apworld.maximum_ap_version and apworld.maximum_ap_version < version_tuple:
|
|
||||||
fail_world(apworld.game,
|
|
||||||
f"Did not load {apworld_source.path} "
|
|
||||||
f"as its maximum core version {apworld.maximum_ap_version} "
|
|
||||||
f"is lower than current core version {version_tuple}.")
|
|
||||||
else:
|
|
||||||
core_compatible.append((apworld_source, apworld))
|
|
||||||
# load highest version first
|
|
||||||
core_compatible.sort(
|
|
||||||
key=lambda element: element[1].world_version if element[1].world_version else Version(0, 0, 0),
|
|
||||||
reverse=True)
|
|
||||||
for apworld_source, apworld in core_compatible:
|
|
||||||
if apworld.game and apworld.game in AutoWorldRegister.world_types:
|
|
||||||
fail_world(apworld.game,
|
|
||||||
f"Did not load {apworld_source.path} "
|
|
||||||
f"as its game {apworld.game} is already loaded.",
|
|
||||||
add_as_failed_to_load=False)
|
|
||||||
else:
|
|
||||||
apworld_source.load()
|
|
||||||
if apworld.game in AutoWorldRegister.world_types:
|
|
||||||
# world could fail to load at this point
|
|
||||||
if apworld.world_version:
|
|
||||||
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
|
|
||||||
load_apworlds()
|
|
||||||
del load_apworlds
|
|
||||||
|
|
||||||
del apworlds
|
|
||||||
|
|
||||||
# Build the data package for each game.
|
# Build the data package for each game.
|
||||||
|
from .AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
network_data_package: DataPackage = {
|
network_data_package: DataPackage = {
|
||||||
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import websockets
|
import websockets
|
||||||
import functools
|
import functools
|
||||||
@@ -210,9 +208,6 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
|||||||
if not ctx.is_proxy_connected():
|
if not ctx.is_proxy_connected():
|
||||||
break
|
break
|
||||||
|
|
||||||
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
|
|
||||||
msg["data"]["time"] = time.time()
|
|
||||||
|
|
||||||
await ctx.send_msgs([msg])
|
await ctx.send_msgs([msg])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ guaranteed_first_acts = [
|
|||||||
"Time Rift - Mafia of Cooks",
|
"Time Rift - Mafia of Cooks",
|
||||||
"Time Rift - Dead Bird Studio",
|
"Time Rift - Dead Bird Studio",
|
||||||
"Time Rift - Sleepy Subcon",
|
"Time Rift - Sleepy Subcon",
|
||||||
"Time Rift - Alpine Skyline",
|
"Time Rift - Alpine Skyline"
|
||||||
"Time Rift - Tour",
|
"Time Rift - Tour",
|
||||||
"Time Rift - Rumbi Factory",
|
"Time Rift - Rumbi Factory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ You only have to do these steps once.
|
|||||||
1. Enter the RetroArch main menu screen.
|
1. Enter the RetroArch main menu screen.
|
||||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||||
Network Command Port at 55355. \
|
Network Command Port at 55355.
|
||||||

|
|
||||||
|

|
||||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||||
Performance)".
|
Performance)".
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ Sólo hay que seguir estos pasos una vez.
|
|||||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||||
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
||||||
el Puerto de comandos de red. \
|
el Puerto de comandos de red.
|
||||||

|
|
||||||
|

|
||||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||||
SFC (bsnes-mercury Performance)".
|
SFC (bsnes-mercury Performance)".
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ Vous n'avez qu'à faire ces étapes qu'une fois.
|
|||||||
1. Entrez dans le menu principal RetroArch
|
1. Entrez dans le menu principal RetroArch
|
||||||
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
|
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
|
||||||
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
|
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
|
||||||
Port des commandes réseau à 555355. \
|
Port des commandes réseau à 555355.
|
||||||

|
|
||||||
|

|
||||||
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
|
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
|
||||||
sélectionnez le.
|
sélectionnez le.
|
||||||
|
|
||||||
|
|||||||
BIN
worlds/alttp/docs/retroarch-network-commands-en.png
Normal file
BIN
worlds/alttp/docs/retroarch-network-commands-en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
worlds/alttp/docs/retroarch-network-commands-fr.png
Normal file
BIN
worlds/alttp/docs/retroarch-network-commands-fr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
import unittest
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
from BaseClasses import MultiWorld, CollectionState
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
|
|
||||||
|
class LTTPTestBase(unittest.TestCase):
|
||||||
|
def world_setup(self):
|
||||||
|
from worlds.alttp.Options import Medallion
|
||||||
|
self.multiworld = MultiWorld(1)
|
||||||
|
self.multiworld.game[1] = "A Link to the Past"
|
||||||
|
self.multiworld.set_seed(None)
|
||||||
|
args = Namespace()
|
||||||
|
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
|
||||||
|
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
|
||||||
|
self.multiworld.set_options(args)
|
||||||
|
self.multiworld.state = CollectionState(self.multiworld)
|
||||||
|
self.world = self.multiworld.worlds[1]
|
||||||
|
# by default medallion access is randomized, for unittests we set it to vanilla
|
||||||
|
self.world.options.misery_mire_medallion.value = Medallion.option_ether
|
||||||
|
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification
|
|
||||||
from worlds import AutoWorldRegister
|
|
||||||
from ..Items import item_factory
|
|
||||||
|
|
||||||
|
|
||||||
class TestBase(unittest.TestCase):
|
|
||||||
multiworld: MultiWorld
|
|
||||||
_state_cache = {}
|
|
||||||
|
|
||||||
def get_state(self, items):
|
|
||||||
if (self.multiworld, tuple(items)) in self._state_cache:
|
|
||||||
return self._state_cache[self.multiworld, tuple(items)]
|
|
||||||
state = CollectionState(self.multiworld)
|
|
||||||
for item in items:
|
|
||||||
item.classification = ItemClassification.progression
|
|
||||||
state.collect(item, prevent_sweep=True)
|
|
||||||
state.sweep_for_advancements()
|
|
||||||
state.update_reachable_regions(1)
|
|
||||||
self._state_cache[self.multiworld, tuple(items)] = state
|
|
||||||
return state
|
|
||||||
|
|
||||||
def get_path(self, state, region):
|
|
||||||
def flist_to_iter(node):
|
|
||||||
while node:
|
|
||||||
value, node = node
|
|
||||||
yield value
|
|
||||||
|
|
||||||
from itertools import zip_longest
|
|
||||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
|
||||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
|
||||||
# Now we combine the flat string list into (region, exit) pairs
|
|
||||||
pathsiter = iter(string_path_flat)
|
|
||||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
|
||||||
return list(pathpairs)
|
|
||||||
|
|
||||||
def run_location_tests(self, access_pool):
|
|
||||||
for i, (location, access, *item_pool) in enumerate(access_pool):
|
|
||||||
items = item_pool[0]
|
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
|
||||||
state = self._get_items(item_pool, all_except)
|
|
||||||
path = self.get_path(state, self.multiworld.get_location(location, 1).parent_region)
|
|
||||||
with self.subTest(msg="Reach Location", location=location, access=access, items=items,
|
|
||||||
all_except=all_except, path=path, entry=i):
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), access,
|
|
||||||
f"failed {self.multiworld.get_location(location, 1)} with: {item_pool}")
|
|
||||||
|
|
||||||
# check for partial solution
|
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
|
||||||
for missing_item in item_pool[0]:
|
|
||||||
with self.subTest(msg="Location reachable without required item", location=location,
|
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_location(location, 1).can_reach(state), False,
|
|
||||||
f"failed {self.multiworld.get_location(location, 1)}: succeeded with "
|
|
||||||
f"{missing_item} removed from: {item_pool}")
|
|
||||||
|
|
||||||
def run_entrance_tests(self, access_pool):
|
|
||||||
for i, (entrance, access, *item_pool) in enumerate(access_pool):
|
|
||||||
items = item_pool[0]
|
|
||||||
all_except = item_pool[1] if len(item_pool) > 1 else None
|
|
||||||
state = self._get_items(item_pool, all_except)
|
|
||||||
path = self.get_path(state, self.multiworld.get_entrance(entrance, 1).parent_region)
|
|
||||||
with self.subTest(msg="Reach Entrance", entrance=entrance, access=access, items=items,
|
|
||||||
all_except=all_except, path=path, entry=i):
|
|
||||||
|
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), access)
|
|
||||||
|
|
||||||
# check for partial solution
|
|
||||||
if not all_except and access: # we are not supposed to be able to reach location with partial inventory
|
|
||||||
for missing_item in item_pool[0]:
|
|
||||||
with self.subTest(msg="Entrance reachable without required item", entrance=entrance,
|
|
||||||
items=item_pool[0], missing_item=missing_item, entry=i):
|
|
||||||
state = self._get_items_partial(item_pool, missing_item)
|
|
||||||
self.assertEqual(self.multiworld.get_entrance(entrance, 1).can_reach(state), False,
|
|
||||||
f"failed {self.multiworld.get_entrance(entrance, 1)} with: {item_pool}")
|
|
||||||
|
|
||||||
def _get_items(self, item_pool, all_except):
|
|
||||||
if all_except and len(all_except) > 0:
|
|
||||||
items = self.multiworld.itempool[:]
|
|
||||||
items = [item for item in items if
|
|
||||||
item.name not in all_except and not ("Bottle" in item.name and "AnyBottle" in all_except)]
|
|
||||||
items.extend(item_factory(item_pool[0], self.multiworld.worlds[1]))
|
|
||||||
else:
|
|
||||||
items = item_factory(item_pool[0], self.multiworld.worlds[1])
|
|
||||||
return self.get_state(items)
|
|
||||||
|
|
||||||
def _get_items_partial(self, item_pool, missing_item):
|
|
||||||
new_items = item_pool[0].copy()
|
|
||||||
new_items.remove(missing_item)
|
|
||||||
items = item_factory(new_items, self.multiworld.worlds[1])
|
|
||||||
return self.get_state(items)
|
|
||||||
|
|
||||||
|
|
||||||
class LTTPTestBase(unittest.TestCase):
|
|
||||||
def world_setup(self):
|
|
||||||
from worlds.alttp.Options import Medallion
|
|
||||||
self.multiworld = MultiWorld(1)
|
|
||||||
self.multiworld.game[1] = "A Link to the Past"
|
|
||||||
self.multiworld.set_seed(None)
|
|
||||||
args = Namespace()
|
|
||||||
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
|
|
||||||
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
|
|
||||||
self.multiworld.set_options(args)
|
|
||||||
self.multiworld.state = CollectionState(self.multiworld)
|
|
||||||
self.world = self.multiworld.worlds[1]
|
|
||||||
# by default medallion access is randomized, for unittests we set it to vanilla
|
|
||||||
self.world.options.misery_mire_medallion.value = Medallion.option_ether
|
|
||||||
self.world.options.turtle_rock_medallion.value = Medallion.option_quake
|
|
||||||
@@ -5,7 +5,7 @@ from worlds.alttp.ItemPool import difficulties
|
|||||||
from worlds.alttp.Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from worlds.alttp.Regions import create_regions
|
from worlds.alttp.Regions import create_regions
|
||||||
from worlds.alttp.Shops import create_shops
|
from worlds.alttp.Shops import create_shops
|
||||||
from worlds.alttp.test.bases import LTTPTestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestDungeon(LTTPTestBase):
|
class TestDungeon(LTTPTestBase):
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...EntranceShuffle import link_inverted_entrances
|
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
||||||
from ...InvertedRegions import create_inverted_regions
|
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Regions import mark_light_world_regions
|
from worlds.alttp.Regions import mark_light_world_regions
|
||||||
from ...Shops import create_shops
|
from worlds.alttp.Shops import create_shops
|
||||||
|
from test.bases import TestBase
|
||||||
|
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInverted(TestBase, LTTPTestBase):
|
class TestInverted(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances
|
|||||||
from worlds.alttp.InvertedRegions import create_inverted_regions
|
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||||
from worlds.alttp.ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from worlds.alttp.Rules import set_inverted_big_bomb_rules
|
from worlds.alttp.Rules import set_inverted_big_bomb_rules
|
||||||
from worlds.alttp.test.bases import LTTPTestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedBombRules(LTTPTestBase):
|
class TestInvertedBombRules(LTTPTestBase):
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...EntranceShuffle import link_inverted_entrances
|
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
||||||
from ...InvertedRegions import create_inverted_regions
|
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Options import GlitchesRequired
|
from worlds.alttp.Options import GlitchesRequired
|
||||||
from ...Regions import mark_light_world_regions
|
from worlds.alttp.Regions import mark_light_world_regions
|
||||||
from ...Shops import create_shops
|
from worlds.alttp.Shops import create_shops
|
||||||
|
from test.bases import TestBase
|
||||||
|
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedMinor(TestBase, LTTPTestBase):
|
class TestInvertedMinor(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...EntranceShuffle import link_inverted_entrances
|
from worlds.alttp.EntranceShuffle import link_inverted_entrances
|
||||||
from ...InvertedRegions import create_inverted_regions
|
from worlds.alttp.InvertedRegions import create_inverted_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Options import GlitchesRequired
|
from worlds.alttp.Options import GlitchesRequired
|
||||||
from ...Regions import mark_light_world_regions
|
from worlds.alttp.Regions import mark_light_world_regions
|
||||||
from ...Shops import create_shops
|
from worlds.alttp.Shops import create_shops
|
||||||
|
from test.bases import TestBase
|
||||||
|
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestInvertedOWG(TestBase, LTTPTestBase):
|
class TestInvertedOWG(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ..bases import TestBase
|
from test.bases import TestBase
|
||||||
|
|
||||||
base_items = 41
|
base_items = 41
|
||||||
extra_counts = (15, 15, 10, 5, 25)
|
extra_counts = (15, 15, 10, 5, 25)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...InvertedRegions import mark_dark_world_regions
|
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Options import GlitchesRequired
|
from test.bases import TestBase
|
||||||
|
from worlds.alttp.Options import GlitchesRequired
|
||||||
|
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestMinor(TestBase, LTTPTestBase):
|
class TestMinor(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...InvertedRegions import mark_dark_world_regions
|
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Options import GlitchesRequired
|
from test.bases import TestBase
|
||||||
|
from worlds.alttp.Options import GlitchesRequired
|
||||||
|
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestVanillaOWG(TestBase, LTTPTestBase):
|
class TestVanillaOWG(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from ...Shops import shop_table
|
from worlds.alttp.Shops import shop_table
|
||||||
from ..bases import TestBase
|
from test.bases import TestBase
|
||||||
|
|
||||||
|
|
||||||
class TestSram(TestBase):
|
class TestSram(TestBase):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from ...Dungeons import get_dungeon_item_pool
|
from worlds.alttp.Dungeons import get_dungeon_item_pool
|
||||||
from ...InvertedRegions import mark_dark_world_regions
|
from worlds.alttp.InvertedRegions import mark_dark_world_regions
|
||||||
from ...ItemPool import difficulties
|
from worlds.alttp.ItemPool import difficulties
|
||||||
from ...Items import item_factory
|
from worlds.alttp.Items import item_factory
|
||||||
from ...Options import GlitchesRequired
|
from test.bases import TestBase
|
||||||
|
from worlds.alttp.Options import GlitchesRequired
|
||||||
from ..bases import LTTPTestBase, TestBase
|
from worlds.alttp.test import LTTPTestBase
|
||||||
|
|
||||||
|
|
||||||
class TestVanilla(TestBase, LTTPTestBase):
|
class TestVanilla(TestBase, LTTPTestBase):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ game you play will make sure that every game has its own save game.
|
|||||||
Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria game folder. The unzipped files are:
|
Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria game folder. The unzipped files are:
|
||||||
- aquaria_randomizer.exe
|
- aquaria_randomizer.exe
|
||||||
- OpenAL32.dll
|
- OpenAL32.dll
|
||||||
- randomizer_files (directory)
|
- override (directory)
|
||||||
- SDL2.dll
|
- SDL2.dll
|
||||||
- usersettings.xml
|
- usersettings.xml
|
||||||
- wrap_oal.dll
|
- wrap_oal.dll
|
||||||
@@ -32,10 +32,7 @@ Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria
|
|||||||
If there is a conflict between files in the original game folder and the unzipped files, you should overwrite
|
If there is a conflict between files in the original game folder and the unzipped files, you should overwrite
|
||||||
the original files with the ones from the unzipped randomizer.
|
the original files with the ones from the unzipped randomizer.
|
||||||
|
|
||||||
There is multiple way to start the game. The easiest one is using the launcher. To do that, just run
|
Finally, to launch the randomizer, you must use the command line interface (you can open the command line interface
|
||||||
the `aquaria_randomizer.exe` file.
|
|
||||||
|
|
||||||
You can also launch the randomizer using the command line interface (you can open the command line interface
|
|
||||||
by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the
|
by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the
|
||||||
randomizer:
|
randomizer:
|
||||||
|
|
||||||
@@ -52,17 +49,15 @@ aquaria_randomizer.exe --name YourName --server theServer:thePort --password th
|
|||||||
### Linux when using the AppImage
|
### Linux when using the AppImage
|
||||||
|
|
||||||
If you use the AppImage, just copy it into the Aquaria game folder. You then have to make it executable. You
|
If you use the AppImage, just copy it into the Aquaria game folder. You then have to make it executable. You
|
||||||
can do that from the command line by using:
|
can do that from command line by using:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x Aquaria_Randomizer-*.AppImage
|
chmod +x Aquaria_Randomizer-*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
or by using the Graphical file Explorer of your system (the permission can generally be set in the file properties).
|
or by using the Graphical Explorer of your system.
|
||||||
|
|
||||||
To launch the randomizer using the integrated launcher, just execute the AppImage file.
|
To launch the randomizer, just launch in command line:
|
||||||
|
|
||||||
You can also use command line arguments to set the server and slot of your game:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
|
./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
|
||||||
@@ -84,7 +79,7 @@ the original game will stop working. Copying the folder will guarantee that the
|
|||||||
|
|
||||||
Untar the Aquaria randomizer release and copy all extracted files in the Aquaria game folder. The extracted files are:
|
Untar the Aquaria randomizer release and copy all extracted files in the Aquaria game folder. The extracted files are:
|
||||||
- aquaria_randomizer
|
- aquaria_randomizer
|
||||||
- randomizer_files (directory)
|
- override (directory)
|
||||||
- usersettings.xml
|
- usersettings.xml
|
||||||
- cacert.pem
|
- cacert.pem
|
||||||
|
|
||||||
@@ -92,7 +87,7 @@ If there is a conflict between files in the original game folder and the extract
|
|||||||
the original files with the ones from the extracted randomizer files.
|
the original files with the ones from the extracted randomizer files.
|
||||||
|
|
||||||
Then, you should use your system package manager to install `liblua5`, `libogg`, `libvorbis`, `libopenal` and `libsdl2`.
|
Then, you should use your system package manager to install `liblua5`, `libogg`, `libvorbis`, `libopenal` and `libsdl2`.
|
||||||
On Debian base systems (like Ubuntu), you can use the following command:
|
On Debian base system (like Ubuntu), you can use the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev
|
sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev
|
||||||
@@ -102,9 +97,7 @@ Also, if there are certain `.so` files in the original Aquaria game folder (`lib
|
|||||||
`libSDL-1.2.so.0` and `libstdc++.so.6`), you should remove them from the Aquaria Randomizer game folder. Those are
|
`libSDL-1.2.so.0` and `libstdc++.so.6`), you should remove them from the Aquaria Randomizer game folder. Those are
|
||||||
old libraries that will not work on the recent build of the randomizer.
|
old libraries that will not work on the recent build of the randomizer.
|
||||||
|
|
||||||
To launch the randomizer using the integrated launcher, just execute the `aquaria_randomizer` file.
|
To launch the randomizer, just launch in command line:
|
||||||
|
|
||||||
You can also use command line arguments to set the server and slot of your game:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./aquaria_randomizer --name YourName --server theServer:thePort
|
./aquaria_randomizer --name YourName --server theServer:thePort
|
||||||
@@ -122,20 +115,6 @@ sure that your executable has executable permission:
|
|||||||
```bash
|
```bash
|
||||||
chmod +x aquaria_randomizer
|
chmod +x aquaria_randomizer
|
||||||
```
|
```
|
||||||
### Steam deck
|
|
||||||
|
|
||||||
On the Steamdeck, go in desktop mode and follow the same procedure as the Linux Appimage.
|
|
||||||
|
|
||||||
|
|
||||||
### No sound on Linux/Steam deck
|
|
||||||
|
|
||||||
If your game play without problems, but with no sound, the game probably does not use the correct
|
|
||||||
driver for the sound system. To fix that, you can use `ALSOFT_DRIVERS=pulse` before your command
|
|
||||||
line to make it work. Something like this (depending on the way you launch the randomizer):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Tracking
|
## Auto-Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Logiciels nécessaires
|
## Logiciels nécessaires
|
||||||
|
|
||||||
- Une copie du jeu Aquaria non modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
|
- Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
|
||||||
- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
|
- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
|
||||||
|
|
||||||
## Logiciels optionnels
|
## Logiciels optionnels
|
||||||
|
|
||||||
- De manière optionnelle, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
|
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
|
||||||
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
|
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
|
||||||
|
|
||||||
## Procédures d'installation et d'exécution
|
## Procédures d'installation et d'exécution
|
||||||
@@ -25,7 +25,7 @@ Désarchiver le randomizer d'Aquaria et copier tous les fichiers de l'archive da
|
|||||||
fichier d'archive devrait contenir les fichiers suivants:
|
fichier d'archive devrait contenir les fichiers suivants:
|
||||||
- aquaria_randomizer.exe
|
- aquaria_randomizer.exe
|
||||||
- OpenAL32.dll
|
- OpenAL32.dll
|
||||||
- randomizer_files (directory)
|
- override (directory)
|
||||||
- SDL2.dll
|
- SDL2.dll
|
||||||
- usersettings.xml
|
- usersettings.xml
|
||||||
- wrap_oal.dll
|
- wrap_oal.dll
|
||||||
@@ -34,10 +34,7 @@ fichier d'archive devrait contenir les fichiers suivants:
|
|||||||
S'il y a des conflits entre les fichiers de l'archive zip et les fichiers du jeu original, vous devez utiliser
|
S'il y a des conflits entre les fichiers de l'archive zip et les fichiers du jeu original, vous devez utiliser
|
||||||
les fichiers contenus dans l'archive zip.
|
les fichiers contenus dans l'archive zip.
|
||||||
|
|
||||||
Il y a plusieurs manières de lancer le randomizer. Le plus simple consiste à utiliser le lanceur intégré en
|
Finalement, pour lancer le randomizer, vous devez utiliser la ligne de commande (vous pouvez ouvrir une interface de
|
||||||
exécutant simplement le fichier `aquaria_randomizer.exe`.
|
|
||||||
|
|
||||||
Il est également possible de lancer le randomizer en utilisant la ligne de commande (vous pouvez ouvrir une interface de
|
|
||||||
ligne de commande, entrez l'adresse `cmd` dans la barre d'adresse de l'explorateur de fichier de Windows). Voici
|
ligne de commande, entrez l'adresse `cmd` dans la barre d'adresse de l'explorateur de fichier de Windows). Voici
|
||||||
la ligne de commande à utiliser pour lancer le randomizer:
|
la ligne de commande à utiliser pour lancer le randomizer:
|
||||||
|
|
||||||
@@ -60,12 +57,9 @@ le mettre exécutable. Vous pouvez mettre le fichier exécutable avec la command
|
|||||||
chmod +x Aquaria_Randomizer-*.AppImage
|
chmod +x Aquaria_Randomizer-*.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
ou bien en utilisant l'explorateur de fichier graphique de votre système (la permission d'exécution est
|
ou bien en utilisant l'explorateur graphique de votre système.
|
||||||
généralement dans les propriétés du fichier).
|
|
||||||
|
|
||||||
Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier AppImage.
|
Pour lancer le randomizer, utiliser la commande suivante:
|
||||||
|
|
||||||
Vous pouvez également lancer le randomizer en spécifiant les informations de connexion dans les arguments de la ligne de commande:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
|
./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
|
||||||
@@ -89,7 +83,7 @@ avant de déposer le randomizer à l'intérieur permet de vous assurer de garder
|
|||||||
Désarchiver le fichier tar et copier tous les fichiers qu'il contient dans le répertoire du jeu d'origine d'Aquaria. Les
|
Désarchiver le fichier tar et copier tous les fichiers qu'il contient dans le répertoire du jeu d'origine d'Aquaria. Les
|
||||||
fichiers extraient du fichier tar devraient être les suivants:
|
fichiers extraient du fichier tar devraient être les suivants:
|
||||||
- aquaria_randomizer
|
- aquaria_randomizer
|
||||||
- randomizer_files (directory)
|
- override (directory)
|
||||||
- usersettings.xml
|
- usersettings.xml
|
||||||
- cacert.pem
|
- cacert.pem
|
||||||
|
|
||||||
@@ -108,10 +102,7 @@ Notez également que s'il y a des fichiers ".so" dans le répertoire d'Aquaria (
|
|||||||
`libSDL-1.2.so.0` and `libstdc++.so.6`), vous devriez les retirer. Il s'agit de vieille version des librairies qui
|
`libSDL-1.2.so.0` and `libstdc++.so.6`), vous devriez les retirer. Il s'agit de vieille version des librairies qui
|
||||||
ne sont plus fonctionnelles dans les systèmes modernes et qui pourrait empêcher le randomizer de fonctionner.
|
ne sont plus fonctionnelles dans les systèmes modernes et qui pourrait empêcher le randomizer de fonctionner.
|
||||||
|
|
||||||
Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier `aquaria_randomizer`.
|
Pour lancer le randomizer, utiliser la commande suivante:
|
||||||
|
|
||||||
Vous pouvez également lancer le randomizer en spécifiant les information de connexion dans les arguments de la
|
|
||||||
ligne de commande:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./aquaria_randomizer --name VotreNom --server LeServeur:LePort
|
./aquaria_randomizer --name VotreNom --server LeServeur:LePort
|
||||||
@@ -129,21 +120,6 @@ pour vous assurer que votre fichier est exécutable:
|
|||||||
```bash
|
```bash
|
||||||
chmod +x aquaria_randomizer
|
chmod +x aquaria_randomizer
|
||||||
```
|
```
|
||||||
### Steam Deck
|
|
||||||
|
|
||||||
Pour installer le randomizer sur la Steam Deck, seulement suivre la procédure pour les fichiers AppImage
|
|
||||||
indiquée précédemment.
|
|
||||||
|
|
||||||
### Aucun son sur Linux/Steam Deck
|
|
||||||
|
|
||||||
Si le jeu fonctionne sans problème, mais qu'il n'y a aucun son, c'est probablement parce que le jeu
|
|
||||||
n'arrive pas à utiliser le bon pilote de son. Généralement, le problème est réglé en ajoutant la
|
|
||||||
variable d'environnement `ALSOFT_DRIVERS=pulse`. Voici un exemple (peut varier en fonction de la manière
|
|
||||||
que le randomizer est lancé):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tracking automatique
|
## Tracking automatique
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"game": "Bumper Stickers",
|
|
||||||
"authors": ["KewlioMZX"],
|
|
||||||
"world_version": "1.0.0",
|
|
||||||
"minimum_ap_version": "0.6.4"
|
|
||||||
}
|
|
||||||
@@ -232,9 +232,11 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
|||||||
# Connect the Regions by named Entrances that must have access Rules
|
# Connect the Regions by named Entrances that must have access Rules
|
||||||
menu_region.connect(start_camp_region)
|
menu_region.connect(start_camp_region)
|
||||||
menu_region.connect(tony_tiddle_mission_region)
|
menu_region.connect(tony_tiddle_mission_region)
|
||||||
menu_region.connect(barn_region, "Barn Door")
|
menu_region.connect(barn_region)
|
||||||
|
tony_tiddle_mission_region.connect(barn_region, "Barn Door")
|
||||||
menu_region.connect(candice_mission_region)
|
menu_region.connect(candice_mission_region)
|
||||||
menu_region.connect(tutorial_house_region, "Tutorial House Door")
|
menu_region.connect(tutorial_house_region)
|
||||||
|
candice_mission_region.connect(tutorial_house_region, "Tutorial House Door")
|
||||||
menu_region.connect(swamp_edges_region)
|
menu_region.connect(swamp_edges_region)
|
||||||
menu_region.connect(swamp_mission_region)
|
menu_region.connect(swamp_mission_region)
|
||||||
menu_region.connect(junkyard_area_region)
|
menu_region.connect(junkyard_area_region)
|
||||||
@@ -242,6 +244,7 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
|||||||
menu_region.connect(junkyard_shed_region)
|
menu_region.connect(junkyard_shed_region)
|
||||||
menu_region.connect(military_base_region)
|
menu_region.connect(military_base_region)
|
||||||
menu_region.connect(south_mine_outside_region)
|
menu_region.connect(south_mine_outside_region)
|
||||||
|
menu_region.connect(south_mine_inside_region)
|
||||||
south_mine_outside_region.connect(south_mine_inside_region, "South Mine Gate")
|
south_mine_outside_region.connect(south_mine_inside_region, "South Mine Gate")
|
||||||
menu_region.connect(middle_station_region)
|
menu_region.connect(middle_station_region)
|
||||||
menu_region.connect(canyon_region)
|
menu_region.connect(canyon_region)
|
||||||
@@ -255,11 +258,13 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
|||||||
menu_region.connect(lost_stairs_region)
|
menu_region.connect(lost_stairs_region)
|
||||||
menu_region.connect(east_house_region)
|
menu_region.connect(east_house_region)
|
||||||
menu_region.connect(rockets_testing_ground_region)
|
menu_region.connect(rockets_testing_ground_region)
|
||||||
|
menu_region.connect(rockets_testing_bunker_region)
|
||||||
rockets_testing_ground_region.connect(rockets_testing_bunker_region, "Stuck Bunker Door")
|
rockets_testing_ground_region.connect(rockets_testing_bunker_region, "Stuck Bunker Door")
|
||||||
menu_region.connect(workshop_region)
|
menu_region.connect(workshop_region)
|
||||||
menu_region.connect(east_tower_region)
|
menu_region.connect(east_tower_region)
|
||||||
menu_region.connect(lighthouse_region)
|
menu_region.connect(lighthouse_region)
|
||||||
menu_region.connect(north_mine_outside_region)
|
menu_region.connect(north_mine_outside_region)
|
||||||
|
menu_region.connect(north_mine_inside_region)
|
||||||
north_mine_outside_region.connect(north_mine_inside_region, "North Mine Gate")
|
north_mine_outside_region.connect(north_mine_inside_region, "North Mine Gate")
|
||||||
menu_region.connect(wood_bridge_region)
|
menu_region.connect(wood_bridge_region)
|
||||||
menu_region.connect(museum_region)
|
menu_region.connect(museum_region)
|
||||||
@@ -273,9 +278,11 @@ def create_regions(world: MultiWorld, options: CCCharlesOptions, player: int) ->
|
|||||||
menu_region.connect(north_beach_region)
|
menu_region.connect(north_beach_region)
|
||||||
menu_region.connect(mine_shaft_region)
|
menu_region.connect(mine_shaft_region)
|
||||||
menu_region.connect(mob_camp_region)
|
menu_region.connect(mob_camp_region)
|
||||||
|
menu_region.connect(mob_camp_locked_room_region)
|
||||||
mob_camp_region.connect(mob_camp_locked_room_region, "Mob Camp Locked Door")
|
mob_camp_region.connect(mob_camp_locked_room_region, "Mob Camp Locked Door")
|
||||||
menu_region.connect(mine_elevator_exit_region)
|
menu_region.connect(mine_elevator_exit_region)
|
||||||
menu_region.connect(mountain_ruin_outside_region)
|
menu_region.connect(mountain_ruin_outside_region)
|
||||||
|
menu_region.connect(mountain_ruin_inside_region)
|
||||||
mountain_ruin_outside_region.connect(mountain_ruin_inside_region, "Mountain Ruin Gate")
|
mountain_ruin_outside_region.connect(mountain_ruin_inside_region, "Mountain Ruin Gate")
|
||||||
menu_region.connect(prism_temple_region)
|
menu_region.connect(prism_temple_region)
|
||||||
menu_region.connect(pickle_val_region)
|
menu_region.connect(pickle_val_region)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from .Options import CCCharlesOptions
|
|||||||
from .Rules import set_rules
|
from .Rules import set_rules
|
||||||
from .Regions import create_regions
|
from .Regions import create_regions
|
||||||
from BaseClasses import Tutorial, ItemClassification
|
from BaseClasses import Tutorial, ItemClassification
|
||||||
from worlds.AutoWorld import World, WebWorld
|
from worlds.AutoWorld import InvalidItemError, World, WebWorld
|
||||||
|
|
||||||
|
|
||||||
class CCCharlesWeb(WebWorld):
|
class CCCharlesWeb(WebWorld):
|
||||||
@@ -157,7 +157,7 @@ class CCCharlesWorld(World):
|
|||||||
case "Bug Spray":
|
case "Bug Spray":
|
||||||
classification = ItemClassification.progression
|
classification = ItemClassification.progression
|
||||||
case _: # Should not occur
|
case _: # Should not occur
|
||||||
raise Exception("Unexpected case met: classification cannot be set for unknown item \"" + name + "\"")
|
raise InvalidItemError("Unexpected case met: classification cannot be set for unknown item \"" + name + "\"")
|
||||||
|
|
||||||
return CCCharlesItem(name, classification, item_id, self.player)
|
return CCCharlesItem(name, classification, item_id, self.player)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
Modified MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 PoryGone
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
|
|
||||||
and to permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
No copy or substantial portion of the Software shall be sublicensed or relicensed
|
|
||||||
without the express written permission of the copyright holder(s)
|
|
||||||
|
|
||||||
No copy or substantial portion of the Software shall be sold without the express
|
|
||||||
written permission of the copyright holder(s)
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"game": "Celeste 64",
|
|
||||||
"authors": [ "PoryGone" ],
|
|
||||||
"minimum_ap_version": "0.6.3",
|
|
||||||
"world_version": "1.3.1"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
Modified MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 PoryGone
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
|
|
||||||
and to permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
No copy or substantial portion of the Software shall be sublicensed or relicensed
|
|
||||||
without the express written permission of the copyright holder(s)
|
|
||||||
|
|
||||||
No copy or substantial portion of the Software shall be sold without the express
|
|
||||||
written permission of the copyright holder(s)
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"game": "Celeste (Open World)",
|
|
||||||
"authors": [ "PoryGone" ],
|
|
||||||
"minimum_ap_version": "0.6.3",
|
|
||||||
"world_version": "1.0.5"
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ class CivVIBoostData:
|
|||||||
Prereq: List[str]
|
Prereq: List[str]
|
||||||
PrereqRequiredCount: int
|
PrereqRequiredCount: int
|
||||||
Classification: str
|
Classification: str
|
||||||
EraRequired: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class GoodyHutRewardData(TypedDict):
|
class GoodyHutRewardData(TypedDict):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user