mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-24 08:33:28 -07:00
Compare commits
1 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ccce7805 |
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
@@ -5,7 +5,7 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
- '*.*.*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
|
|||||||
7
.github/workflows/unittests.yml
vendored
7
.github/workflows/unittests.yml
vendored
@@ -41,13 +41,12 @@ jobs:
|
|||||||
python:
|
python:
|
||||||
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
- {version: '3.13'}
|
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.11'} # old compat
|
- python: {version: '3.11'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.13'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.13'} # current
|
- python: {version: '3.12'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -75,7 +74,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.13'} # current
|
- {version: '3.12'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_current_datapackage(self) -> dict[str, typing.Any]:
|
||||||
|
"""
|
||||||
|
Return datapackage for current game if known.
|
||||||
|
|
||||||
|
:return: The datapackage for the currently registered game. If not found, an empty dictionary will be returned.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
return {}
|
||||||
|
checksum = self.ctx.checksums[self.ctx.game]
|
||||||
|
return Utils.load_data_package_for_checksum(self.ctx.game, checksum)
|
||||||
|
|
||||||
def _cmd_missing(self, filter_text = "") -> bool:
|
def _cmd_missing(self, filter_text = "") -> bool:
|
||||||
"""List all missing location checks, from your local game state.
|
"""List all missing location checks, from your local game state.
|
||||||
Can be given text, which will be used as filter."""
|
Can be given text, which will be used as filter."""
|
||||||
@@ -108,8 +119,8 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
|
|
||||||
lookup = self.ctx.location_names[self.ctx.game]
|
lookup = self.get_current_datapackage().get("location_name_to_id", {})
|
||||||
for location_id, location in lookup.items():
|
for location, location_id in lookup.items():
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -130,10 +141,11 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
def output_datapackage_part(self, key: str, name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Helper to digest a specific section of this game's datapackage.
|
Helper to digest a specific section of this game's datapackage.
|
||||||
|
|
||||||
|
:param key: The dictionary key in the datapackage.
|
||||||
:param name: Printed to the user as context for the part.
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
:return: Whether the process was successful.
|
:return: Whether the process was successful.
|
||||||
@@ -142,20 +154,23 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output(f"No game set, cannot determine {name}.")
|
self.output(f"No game set, cannot determine {name}.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
lookup = self.get_current_datapackage().get(key)
|
||||||
lookup = lookup[self.ctx.game]
|
if lookup is None:
|
||||||
|
self.output("datapackage not yet loaded, try again")
|
||||||
|
return False
|
||||||
|
|
||||||
self.output(f"{name} for {self.ctx.game}")
|
self.output(f"{name} for {self.ctx.game}")
|
||||||
for name in lookup.values():
|
for key in lookup:
|
||||||
self.output(name)
|
self.output(key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_items(self) -> bool:
|
def _cmd_items(self) -> bool:
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
return self.output_datapackage_part("Item Names")
|
return self.output_datapackage_part("item_name_to_id", "Item Names")
|
||||||
|
|
||||||
def _cmd_locations(self) -> bool:
|
def _cmd_locations(self) -> bool:
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
return self.output_datapackage_part("Location Names")
|
return self.output_datapackage_part("location_name_to_id", "Location Names")
|
||||||
|
|
||||||
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||||
filter_key: str,
|
filter_key: str,
|
||||||
@@ -856,9 +871,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 ""
|
||||||
|
|||||||
8
Fill.py
8
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]
|
||||||
@@ -553,12 +549,10 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if prioritylocations and regular_progression:
|
if prioritylocations and regular_progression:
|
||||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
||||||
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
|
||||||
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority Retry", one_item_per_player=False,
|
name="Priority Retry", one_item_per_player=False, allow_partial=True)
|
||||||
allow_partial=bool(deprioritized_progression))
|
|
||||||
|
|
||||||
if prioritylocations and deprioritized_progression:
|
if prioritylocations and deprioritized_progression:
|
||||||
# There are no more regular progression items that can be placed on any priority locations.
|
# There are no more regular progression items that can be placed on any priority locations.
|
||||||
|
|||||||
54
Generate.py
54
Generate.py
@@ -166,10 +166,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
args.outputname = seed_name
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
erargs.seed = seed
|
||||||
args.name = {}
|
erargs.plando_options = args.plando
|
||||||
|
erargs.spoiler = args.spoiler
|
||||||
|
erargs.race = args.race
|
||||||
|
erargs.outputname = seed_name
|
||||||
|
erargs.outputpath = args.outputpath
|
||||||
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
|
erargs.skip_output = args.skip_output
|
||||||
|
erargs.spoiler_only = args.spoiler_only
|
||||||
|
erargs.name = {}
|
||||||
|
erargs.csv_output = args.csv_output
|
||||||
|
|
||||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
@@ -196,7 +205,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
args.player_options = {}
|
erargs.player_options = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
@@ -209,21 +218,21 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
try:
|
try:
|
||||||
getattr(args, k)[player] = v
|
getattr(erargs, k)[player] = v
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
setattr(args, k, {player: v})
|
setattr(erargs, k, {player: v})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||||
|
|
||||||
# name was not specified
|
# name was not specified
|
||||||
if player not in args.name:
|
if player not in erargs.name:
|
||||||
if path == args.weights_file_path:
|
if path == args.weights_file_path:
|
||||||
# weights file, so we need to make the name unique
|
# weights file, so we need to make the name unique
|
||||||
args.name[player] = f"Player{player}"
|
erargs.name[player] = f"Player{player}"
|
||||||
else:
|
else:
|
||||||
# use the filename
|
# use the filename
|
||||||
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -231,10 +240,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in args.name.values())) != len(args.name):
|
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||||
|
|
||||||
return args, seed
|
return erargs, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||||
@@ -486,22 +495,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()
|
||||||
@@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
multiprocessing.freeze_support()
|
Utils.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Archipelago Launcher',
|
description='Archipelago Launcher',
|
||||||
|
|||||||
@@ -412,10 +412,10 @@ class LinksAwakeningClient():
|
|||||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||||
|
|
||||||
item_id -= LABaseID
|
item_id -= LABaseID
|
||||||
# The player name table only goes up to 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, [
|
||||||
|
|||||||
13
Main.py
13
Main.py
@@ -37,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando
|
multiworld.plando_options = args.plando_options
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -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
|
||||||
|
|||||||
180
MultiServer.py
180
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,30 +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)
|
||||||
"version",
|
tags: typing.List[str]
|
||||||
"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
|
||||||
@@ -166,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
|
||||||
@@ -174,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):
|
||||||
@@ -1172,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():
|
||||||
@@ -1194,38 +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
|
||||||
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
|
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||||
else:
|
item_flags, new_status))
|
||||||
status = HintStatus.HINT_PRIORITY
|
|
||||||
|
|
||||||
hints.append(
|
|
||||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
|
|
||||||
)
|
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
|
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]
|
||||||
@@ -1235,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 []
|
||||||
|
|
||||||
|
|
||||||
@@ -1358,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):
|
||||||
@@ -1388,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:")
|
||||||
@@ -1656,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]}
|
||||||
@@ -1681,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]
|
||||||
@@ -1703,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)
|
||||||
@@ -1992,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:
|
||||||
@@ -2007,16 +1961,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
if not locations:
|
if not locations:
|
||||||
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
status = HintStatus(status)
|
|
||||||
except ValueError as err:
|
|
||||||
await ctx.send_msgs(client,
|
|
||||||
[{"cmd": "InvalidPacket", "type": "arguments",
|
|
||||||
"text": f"Unknown Status: {err}",
|
|
||||||
"original_cmd": cmd}])
|
|
||||||
return
|
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
|
|
||||||
@@ -2284,19 +2228,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."""
|
||||||
@@ -2418,9 +2349,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)
|
||||||
@@ -2454,14 +2385,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:
|
||||||
@@ -2676,13 +2610,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):
|
||||||
|
|||||||
10
Options.py
10
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),
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1753,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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Currently, the following games are supported:
|
|||||||
* Meritous
|
* Meritous
|
||||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||||
* ChecksFinder
|
* ChecksFinder
|
||||||
|
* ArchipIDLE
|
||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
@@ -80,8 +81,6 @@ Currently, the following games are supported:
|
|||||||
* Super Mario Land 2: 6 Golden Coins
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
* shapez
|
* shapez
|
||||||
* Paint
|
* Paint
|
||||||
* Celeste (Open World)
|
|
||||||
* Choo-Choo Charles
|
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
11
Starcraft2Client.py
Normal file
11
Starcraft2Client.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
from worlds.sc2.Client import launch
|
||||||
|
import Utils
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
||||||
|
launch()
|
||||||
39
Utils.py
39
Utils.py
@@ -35,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):
|
||||||
@@ -322,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))
|
||||||
|
|
||||||
@@ -720,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
|
||||||
@@ -951,15 +940,15 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||||
# original upstream issue: https://github.com/python/cpython/issues/76327
|
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import multiprocessing.spawn
|
import multiprocessing.spawn
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
def _freeze_support() -> None:
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
from subprocess import _args_from_interpreter_flags # noqa
|
from subprocess import _args_from_interpreter_flags
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
@@ -986,23 +975,17 @@ def _extend_freeze_support() -> None:
|
|||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
def _noop() -> None:
|
if not is_windows and is_frozen():
|
||||||
pass
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
||||||
|
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
|
||||||
|
|
||||||
|
|
||||||
def freeze_support() -> None:
|
def freeze_support() -> None:
|
||||||
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
|
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
_extend_freeze_support()
|
||||||
deprecate("Use multiprocessing.freeze_support() instead")
|
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
|
||||||
_extend_freeze_support()
|
|
||||||
|
|
||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
|
|||||||
@@ -99,23 +99,16 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
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"]:
|
||||||
|
|||||||
@@ -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,120 @@ 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)
|
activity_timers[team]["player_timers"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
break
|
|
||||||
|
|
||||||
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 +151,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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ from flask import flash, redirect, render_template, request, session, url_for
|
|||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
from Generate import PlandoOptions, handle_name
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__, restricted_dumps
|
from Utils import __version__, restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
@@ -72,10 +73,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"]))
|
||||||
|
|
||||||
@@ -96,9 +93,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()
|
||||||
|
|
||||||
@@ -110,9 +105,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
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))
|
||||||
|
|
||||||
@@ -136,39 +129,36 @@ 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()
|
erargs = parse_arguments(['--multi', str(playercount)])
|
||||||
args.multi = playercount
|
erargs.seed = seed
|
||||||
args.seed = seed
|
erargs.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
|
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
erargs.race = race
|
||||||
args.race = race
|
erargs.outputname = seedname
|
||||||
args.outputname = seedname
|
erargs.outputpath = target.name
|
||||||
args.outputpath = target.name
|
erargs.teams = 1
|
||||||
args.teams = 1
|
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
{"bosses", "items", "connections", "texts"}))
|
||||||
{"bosses", "items", "connections", "texts"}))
|
erargs.skip_prog_balancing = False
|
||||||
args.skip_prog_balancing = False
|
erargs.skip_output = False
|
||||||
args.skip_output = False
|
erargs.spoiler_only = False
|
||||||
args.spoiler_only = False
|
erargs.csv_output = False
|
||||||
args.csv_output = False
|
|
||||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
|
||||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
for k, v in settings.items():
|
for k, v in settings.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
if hasattr(args, k):
|
if hasattr(erargs, k):
|
||||||
getattr(args, k)[player] = v
|
getattr(erargs, k)[player] = v
|
||||||
else:
|
else:
|
||||||
setattr(args, k, {player: v})
|
setattr(erargs, k, {player: v})
|
||||||
|
|
||||||
if not args.name[player]:
|
if not erargs.name[player]:
|
||||||
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
if len(set(args.name.values())) != len(args.name):
|
if len(set(erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
@@ -183,9 +173,9 @@ 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 BaseException as e:
|
except BaseException as e:
|
||||||
@@ -195,7 +185,7 @@ 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
|
||||||
@@ -212,9 +202,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import threading
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
def update_sprites_lttp():
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
from tkinter import Tk
|
from tkinter import Tk
|
||||||
from LttPAdjuster import get_image_for_sprite
|
from LttPAdjuster import get_image_for_sprite
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
|
|||||||
@@ -260,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"
|
||||||
@@ -271,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
|
||||||
@@ -284,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)
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,7 @@ def generate_weighted_yaml(game: str):
|
|||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
for key, val in request.form.items():
|
for key, val in request.form.items():
|
||||||
if val == "_ensure-empty-list":
|
if "||" not in key:
|
||||||
options[key] = {}
|
|
||||||
elif "||" not in key:
|
|
||||||
if len(str(val)) == 0:
|
if len(str(val)) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -214,11 +212,8 @@ def generate_yaml(game: str):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
options = {}
|
options = {}
|
||||||
intent_generate = False
|
intent_generate = False
|
||||||
|
|
||||||
for key, val in request.form.items(multi=True):
|
for key, val in request.form.items(multi=True):
|
||||||
if val == "_ensure-empty-list":
|
if key in options:
|
||||||
options[key] = []
|
|
||||||
elif options.get(key):
|
|
||||||
if not isinstance(options[key], list):
|
if not isinstance(options[key], list):
|
||||||
options[key] = [options[key]]
|
options[key] = [options[key]]
|
||||||
options[key].append(val)
|
options[key].append(val)
|
||||||
@@ -231,7 +226,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]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
flask>=3.1.1
|
flask>=3.1.1
|
||||||
werkzeug>=3.1.3
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19; python_version <= '3.12'
|
pony>=0.7.19
|
||||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
|
||||||
waitress>=3.0.2
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.17
|
Flask-Compress>=1.17
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -1,43 +1,49 @@
|
|||||||
let updateSection = (sectionName, fakeDOM) => {
|
|
||||||
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
// Reload tracker every 60 seconds (sync'd)
|
// Reload tracker every 15 seconds
|
||||||
const url = window.location;
|
const url = window.location;
|
||||||
// Note: This synchronization code is adapted from code in trackerCommon.js
|
setInterval(() => {
|
||||||
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
const ajax = new XMLHttpRequest();
|
||||||
console.log("Target second of refresh: " + targetSecond);
|
ajax.onreadystatechange = () => {
|
||||||
|
if (ajax.readyState !== 4) { return; }
|
||||||
|
|
||||||
let getSleepTimeSeconds = () => {
|
// Create a fake DOM using the returned HTML
|
||||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
const domParser = new DOMParser();
|
||||||
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
return sleepSeconds || 60;
|
|
||||||
};
|
|
||||||
|
|
||||||
let updateTracker = () => {
|
// Update item tracker
|
||||||
const ajax = new XMLHttpRequest();
|
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||||
ajax.onreadystatechange = () => {
|
// Update only counters in the location-table
|
||||||
if (ajax.readyState !== 4) { return; }
|
let counters = document.getElementsByClassName('counter');
|
||||||
|
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||||
// Create a fake DOM using the returned HTML
|
for (let i = 0; i < counters.length; i++) {
|
||||||
const domParser = new DOMParser();
|
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
}
|
||||||
|
|
||||||
// Update dynamic sections
|
|
||||||
updateSection('player-info', fakeDOM);
|
|
||||||
updateSection('section-filler', fakeDOM);
|
|
||||||
updateSection('section-terran', fakeDOM);
|
|
||||||
updateSection('section-zerg', fakeDOM);
|
|
||||||
updateSection('section-protoss', fakeDOM);
|
|
||||||
updateSection('section-nova', fakeDOM);
|
|
||||||
updateSection('section-kerrigan', fakeDOM);
|
|
||||||
updateSection('section-keys', fakeDOM);
|
|
||||||
updateSection('section-locations', fakeDOM);
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
|
||||||
};
|
};
|
||||||
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
ajax.open('GET', url);
|
||||||
|
ajax.send();
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
// Collapsible advancement sections
|
||||||
|
const categories = document.getElementsByClassName("location-category");
|
||||||
|
for (let category of categories) {
|
||||||
|
let hide_id = category.id.split('_')[0];
|
||||||
|
if (hide_id === 'Total') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
category.addEventListener('click', function() {
|
||||||
|
// Toggle the advancement list
|
||||||
|
document.getElementById(hide_id).classList.toggle("hide");
|
||||||
|
// Change text of the header
|
||||||
|
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
||||||
|
const orig_text = tab_header.innerHTML;
|
||||||
|
let new_text;
|
||||||
|
if (orig_text.includes("▼")) {
|
||||||
|
new_text = orig_text.replace("▼", "▲");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
new_text = orig_text.replace("▲", "▼");
|
||||||
|
}
|
||||||
|
tab_header.innerHTML = new_text;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,279 +1,160 @@
|
|||||||
*{
|
#player-tracker-wrapper{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
}
|
|
||||||
body{
|
|
||||||
--icon-size: 36px;
|
|
||||||
--item-class-padding: 4px;
|
|
||||||
}
|
|
||||||
a{
|
|
||||||
color: #1ae;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section colours */
|
#tracker-table td {
|
||||||
#player-info{
|
vertical-align: top;
|
||||||
background-color: #37a;
|
|
||||||
}
|
|
||||||
.player-tracker{
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.tracker-section{
|
|
||||||
background-color: grey;
|
|
||||||
}
|
|
||||||
#terran-items{
|
|
||||||
background-color: #3a7;
|
|
||||||
}
|
|
||||||
#zerg-items{
|
|
||||||
background-color: #d94;
|
|
||||||
}
|
|
||||||
#protoss-items{
|
|
||||||
background-color: #37a;
|
|
||||||
}
|
|
||||||
#nova-items{
|
|
||||||
background-color: #777;
|
|
||||||
}
|
|
||||||
#kerrigan-items{
|
|
||||||
background-color: #a37;
|
|
||||||
}
|
|
||||||
#keys{
|
|
||||||
background-color: #aa2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections */
|
.inventory-table-area{
|
||||||
.section-body{
|
border: 2px solid #000000;
|
||||||
display: flex;
|
border-radius: 4px;
|
||||||
flex-flow: row wrap;
|
padding: 3px 10px 3px 10px;
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-bottom: 3px;
|
|
||||||
}
|
|
||||||
.section-body-2{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
|
|
||||||
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.section-title{
|
|
||||||
position: relative;
|
|
||||||
border-bottom: 3px solid black;
|
|
||||||
/* Prevent text selection */
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
}
|
|
||||||
input[type="checkbox"]{
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: 1;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.section-title:hover h2{
|
|
||||||
text-shadow: 0 0 4px #ddd;
|
|
||||||
}
|
|
||||||
.f {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Acquire item filters */
|
.inventory-table-area:has(.inventory-table-terran) {
|
||||||
.tracker-section img{
|
width: 690px;
|
||||||
height: 100%;
|
background-color: #525494;
|
||||||
width: var(--icon-size);
|
|
||||||
height: var(--icon-size);
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
.unacquired, .lvl-0 .f{
|
|
||||||
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
|
|
||||||
}
|
|
||||||
.spacer{
|
|
||||||
width: var(--icon-size);
|
|
||||||
height: var(--icon-size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item groups */
|
.inventory-table-area:has(.inventory-table-zerg) {
|
||||||
.item-class{
|
width: 360px;
|
||||||
display: flex;
|
background-color: #9d60d2;
|
||||||
flex-flow: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--item-class-padding);
|
|
||||||
}
|
|
||||||
.item-class-header{
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row;
|
|
||||||
}
|
|
||||||
.item-class-upgrades{
|
|
||||||
/* Note: {display: flex; flex-flow: column wrap} */
|
|
||||||
/* just breaks on Firefox (width does not scale to content) */
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: repeat(4, auto);
|
|
||||||
grid-auto-flow: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subsections */
|
.inventory-table-area:has(.inventory-table-protoss) {
|
||||||
.section-toc{
|
width: 400px;
|
||||||
display: flex;
|
background-color: #d2b260;
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.toc-box{
|
|
||||||
position: relative;
|
|
||||||
padding-left: 15px;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
|
||||||
.toc-box:hover{
|
|
||||||
text-shadow: 0 0 7px white;
|
|
||||||
}
|
|
||||||
.ss-header{
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
writing-mode: sideways-lr;
|
|
||||||
user-select: none;
|
|
||||||
padding-top: 5px;
|
|
||||||
font-size: 115%;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
|
||||||
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
|
|
||||||
background-color: #fff5;
|
|
||||||
box-shadow: 0 0 1px 1px white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progressive items */
|
#tracker-table .inventory-table td{
|
||||||
.progressive{
|
width: 40px;
|
||||||
max-height: var(--icon-size);
|
height: 40px;
|
||||||
display: contents;
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lvl-0 > :nth-child(2),
|
.inventory-table td.title{
|
||||||
.lvl-0 > :nth-child(3),
|
padding-top: 10px;
|
||||||
.lvl-0 > :nth-child(4),
|
height: 20px;
|
||||||
.lvl-0 > :nth-child(5){
|
font-family: "JuraBook", monospace;
|
||||||
display: none;
|
font-size: 16px;
|
||||||
}
|
font-weight: bold;
|
||||||
.lvl-1 > :nth-child(2),
|
|
||||||
.lvl-1 > :nth-child(3),
|
|
||||||
.lvl-1 > :nth-child(4),
|
|
||||||
.lvl-1 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-2 > :nth-child(1),
|
|
||||||
.lvl-2 > :nth-child(3),
|
|
||||||
.lvl-2 > :nth-child(4),
|
|
||||||
.lvl-2 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-3 > :nth-child(1),
|
|
||||||
.lvl-3 > :nth-child(2),
|
|
||||||
.lvl-3 > :nth-child(4),
|
|
||||||
.lvl-3 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-4 > :nth-child(1),
|
|
||||||
.lvl-4 > :nth-child(2),
|
|
||||||
.lvl-4 > :nth-child(3),
|
|
||||||
.lvl-4 > :nth-child(5){
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.lvl-5 > :nth-child(1),
|
|
||||||
.lvl-5 > :nth-child(2),
|
|
||||||
.lvl-5 > :nth-child(3),
|
|
||||||
.lvl-5 > :nth-child(4){
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filler item counters */
|
.inventory-table img{
|
||||||
.item-counter{
|
height: 100%;
|
||||||
display: table;
|
max-width: 40px;
|
||||||
text-align: center;
|
max-height: 40px;
|
||||||
padding: var(--item-class-padding);
|
border: 1px solid #000000;
|
||||||
}
|
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||||
.item-count{
|
background-color: black;
|
||||||
display: table-cell;
|
|
||||||
vertical-align: middle;
|
|
||||||
padding-left: 3px;
|
|
||||||
padding-right: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden items */
|
.inventory-table img.acquired{
|
||||||
.hidden-class:not(:has(img.acquired)){
|
filter: none;
|
||||||
display: none;
|
background-color: black;
|
||||||
}
|
|
||||||
.hidden-item:not(.acquired){
|
|
||||||
display:none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keys */
|
.inventory-table .tint-terran img.acquired {
|
||||||
#keys ol, #keys ul{
|
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
||||||
columns: 3;
|
|
||||||
-webkit-columns: 3;
|
|
||||||
-moz-columns: 3;
|
|
||||||
}
|
|
||||||
#keys li{
|
|
||||||
padding-right: 15pt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locations */
|
.inventory-table .tint-protoss img.acquired {
|
||||||
#section-locations{
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
@media only screen and (min-width: 120ch){
|
|
||||||
#section-locations ul{
|
|
||||||
columns: 2;
|
|
||||||
-webkit-columns: 2;
|
|
||||||
-moz-columns: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#locations li.checked{
|
|
||||||
list-style-type: "✔ ";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Allowing scrolling down a little further */
|
.inventory-table .tint-level-1 img.acquired {
|
||||||
.bottom-padding{
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
||||||
min-height: 33vh;
|
}
|
||||||
}
|
|
||||||
|
.inventory-table .tint-level-2 img.acquired {
|
||||||
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table .tint-level-3 img.acquired {
|
||||||
|
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table div.counted-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-table div.item-count {
|
||||||
|
width: 160px;
|
||||||
|
text-align: left;
|
||||||
|
color: black;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
border: 2px solid #000000;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #87b678;
|
||||||
|
padding: 10px 3px 3px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table table{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table th{
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td{
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.counter {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.toggle-arrow {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tr#Total-header {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tbody.locations {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.location-name {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td:has(.location-column) {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table .location-column {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table .location-column .spacer {
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)] }}
|
||||||
|
|||||||
@@ -134,7 +134,6 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -147,7 +146,6 @@
|
|||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -171,7 +169,6 @@
|
|||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -195,7 +192,6 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -139,7 +139,6 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="list-container">
|
<div class="list-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="list-entry">
|
<div class="list-entry">
|
||||||
@@ -159,7 +158,6 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro LocationSet(option_name, option, world) %}
|
{% macro LocationSet(option_name, option, world) %}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -182,7 +180,6 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemSet(option_name, option, world) %}
|
{% macro ItemSet(option_name, option, world) %}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -205,7 +202,6 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="set-entry">
|
<div class="set-entry">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,6 @@ from worlds.tloz.Items import item_game_ids
|
|||||||
from worlds.tloz.Locations import location_ids
|
from worlds.tloz.Locations import location_ids
|
||||||
from worlds.tloz import Items, Locations, Rom
|
from worlds.tloz import Items, Locations, Rom
|
||||||
|
|
||||||
from settings import get_settings
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
||||||
@@ -343,12 +341,13 @@ if __name__ == '__main__':
|
|||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
Utils.init_logging("ZeldaClient")
|
Utils.init_logging("ZeldaClient")
|
||||||
|
|
||||||
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
|
options = Utils.get_options()
|
||||||
|
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = typing.cast(typing.Union[bool, str],
|
||||||
get_settings()["tloz_options"].get("rom_start", True))
|
Utils.get_options()["tloz_options"].get("rom_start", True))
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
@@ -220,8 +220,6 @@
|
|||||||
<MessageBoxLabel>:
|
<MessageBoxLabel>:
|
||||||
theme_text_color: "Custom"
|
theme_text_color: "Custom"
|
||||||
text_color: 1, 1, 1, 1
|
text_color: 1, 1, 1, 1
|
||||||
<MessageBox>:
|
|
||||||
height: self.content.texture_size[1] + 80
|
|
||||||
<ScrollBox>:
|
<ScrollBox>:
|
||||||
layout: layout
|
layout: layout
|
||||||
bar_width: "12dp"
|
bar_width: "12dp"
|
||||||
@@ -235,3 +233,8 @@
|
|||||||
spacing: 10
|
spacing: 10
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: self.minimum_height
|
height: self.minimum_height
|
||||||
|
<MessageBoxLabel>:
|
||||||
|
valign: "middle"
|
||||||
|
halign: "center"
|
||||||
|
text_size: self.width, None
|
||||||
|
height: self.texture_size[1]
|
||||||
|
|||||||
@@ -33,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.
|
||||||
|
|||||||
7
data/sprites/custom/link.apsprite
Normal file
7
data/sprites/custom/link.apsprite
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
author: Nintendo
|
||||||
|
data: null
|
||||||
|
game: A Link to the Past
|
||||||
|
min_format_version: 1
|
||||||
|
name: Link
|
||||||
|
format_version: 1
|
||||||
|
sprite_version: 1
|
||||||
2
data/sprites/remote/.gitignore
vendored
Normal file
2
data/sprites/remote/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
# Aquaria
|
# Aquaria
|
||||||
/worlds/aquaria/ @tioui
|
/worlds/aquaria/ @tioui
|
||||||
|
|
||||||
|
# ArchipIDLE
|
||||||
|
/worlds/archipidle/ @LegendaryLinux
|
||||||
|
|
||||||
# Blasphemous
|
# Blasphemous
|
||||||
/worlds/blasphemous/ @TRPG0
|
/worlds/blasphemous/ @TRPG0
|
||||||
|
|
||||||
@@ -39,15 +42,9 @@
|
|||||||
# Celeste 64
|
# Celeste 64
|
||||||
/worlds/celeste64/ @PoryGone
|
/worlds/celeste64/ @PoryGone
|
||||||
|
|
||||||
# Celeste (Open World)
|
|
||||||
/worlds/celeste_open_world/ @PoryGone
|
|
||||||
|
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
/worlds/checksfinder/ @SunCatMC
|
/worlds/checksfinder/ @SunCatMC
|
||||||
|
|
||||||
# Choo-Choo Charles
|
|
||||||
/worlds/cccharles/ @Yaranorgoth
|
|
||||||
|
|
||||||
# Civilization VI
|
# Civilization VI
|
||||||
/worlds/civ6/ @hesto2
|
/worlds/civ6/ @hesto2
|
||||||
|
|
||||||
@@ -72,9 +69,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 +238,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/
|
||||||
|
|
||||||
|
|||||||
@@ -62,24 +62,6 @@ if possible.
|
|||||||
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
||||||
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
||||||
|
|
||||||
### Launcher Integration
|
|
||||||
|
|
||||||
If you have a python client or want to utilize the integration features of the Archipelago Launcher (ex. Slot links in
|
|
||||||
webhost) you can define a Component to be a part of the Launcher. `LauncherComponents.components` can be appended to
|
|
||||||
with additional Components in order to automatically add them to the Launcher. Most Components only need a
|
|
||||||
`display_name` and `func`, but `supports_uri` and `game_name` can be defined to support launching by webhost links,
|
|
||||||
`icon` and `description` can be used to customize display in the Launcher UI, and `file_identifier` can be used to
|
|
||||||
launch by file.
|
|
||||||
|
|
||||||
Additionally, if you use `func` you have access to LauncherComponent.launch or launch_subprocess to run your
|
|
||||||
function as a subprocesses that can be utilized side by side other clients.
|
|
||||||
```py
|
|
||||||
def my_func(*args: str):
|
|
||||||
from .client import run_client
|
|
||||||
LauncherComponent.launch(run_client, name="My Client", args=args)
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## World
|
## World
|
||||||
|
|
||||||
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
|
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
|
||||||
|
|||||||
@@ -19,21 +19,7 @@ the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__i
|
|||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
|
No metadata is specified yet.
|
||||||
The current format version has at minimum:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 6,
|
|
||||||
"compatible_version": 5,
|
|
||||||
"game": "Game Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
|
|
||||||
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
|
||||||
Archipelago version respectively to filter those files from being loaded
|
|
||||||
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
|
||||||
An apworld without a world_version is always treated as older than one with a version
|
|
||||||
|
|
||||||
|
|
||||||
## Extra Data
|
## Extra Data
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ What you'll need:
|
|||||||
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
|
* [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version
|
||||||
* On Windows, please consider only using the latest supported version in production environments since security
|
* On Windows, please consider only using the latest supported version in production environments since security
|
||||||
updates for older versions are not easily available.
|
updates for older versions are not easily available.
|
||||||
* Python 3.13.x is currently the newest supported version
|
* Python 3.12.x is currently the newest supported version
|
||||||
* pip: included in downloads from python.org, separate in many Linux distributions
|
* pip: included in downloads from python.org, separate in many Linux distributions
|
||||||
* Matching C compiler
|
* Matching C compiler
|
||||||
* possibly optional, read operating system specific sections
|
* possibly optional, read operating system specific sections
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ if it does not exist.
|
|||||||
## Global Settings
|
## Global Settings
|
||||||
|
|
||||||
All non-world-specific settings are defined directly in settings.py.
|
All non-world-specific settings are defined directly in settings.py.
|
||||||
Each value needs to have a default. If the default should be `None`, 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
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Shared Cache
|
|
||||||
|
|
||||||
Archipelago maintains a shared folder of information that can be persisted for a machine and reused across Libraries.
|
|
||||||
It can be found at the User Cache Directory for appname `Archipelago` in the `Cache` subfolder
|
|
||||||
(ex. `%LOCALAPPDATA%/Archipelago/Cache`).
|
|
||||||
|
|
||||||
## Common Cache
|
|
||||||
|
|
||||||
The Common Cache `common.json` can be used to store any generic data that is expected to be shared across programs
|
|
||||||
for the same User.
|
|
||||||
|
|
||||||
* `uuid`: A UUID identifier used to identify clients as from the same user/machine, to be sent in the Connect packet
|
|
||||||
|
|
||||||
## Data Package Cache
|
|
||||||
|
|
||||||
The `datapackage` folder in the shared cache folder is used to store datapackages by game and checksum to be reused
|
|
||||||
in order to save network traffic. The expected structure is `datapackage/Game Name/checksum_value.json` with the
|
|
||||||
contents of each json file being the no-whitespace datapackage contents.
|
|
||||||
@@ -15,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
|
||||||
@@ -554,4 +554,4 @@ Example:
|
|||||||
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
"seed_id": "a528e34c-3b4f-42a9-9f8f-00a4fd40bacb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ webhost:
|
|||||||
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
* `game_info_languages` (optional) list of strings for defining the existing game info pages your game supports. The
|
||||||
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
documents must be prefixed with the same string as defined here. Default already has 'en'.
|
||||||
|
|
||||||
* `options_presets` (optional) `dict[str, dict[str, Any]]` where the keys are the names of the presets and the values
|
* `options_presets` (optional) `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
|
||||||
are the options to be set for that preset. The options are defined as a `dict[str, Any]` where the keys are the names
|
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names
|
||||||
of the options and the values are the values to be set for that option. These presets will be available for users to
|
of the options and the values are the values to be set for that option. These presets will be available for users to
|
||||||
select from on the game's options page.
|
select from on the game's options page.
|
||||||
|
|
||||||
@@ -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")
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -74,12 +74,13 @@ class EntranceLookup:
|
|||||||
if entrance in self._expands_graph_cache:
|
if entrance in self._expands_graph_cache:
|
||||||
return self._expands_graph_cache[entrance]
|
return self._expands_graph_cache[entrance]
|
||||||
|
|
||||||
seen = {entrance.connected_region}
|
visited = set()
|
||||||
q: deque[Region] = deque()
|
q: deque[Region] = deque()
|
||||||
q.append(entrance.connected_region)
|
q.append(entrance.connected_region)
|
||||||
|
|
||||||
while q:
|
while q:
|
||||||
region = q.popleft()
|
region = q.popleft()
|
||||||
|
visited.add(region)
|
||||||
|
|
||||||
# check if the region itself is progression
|
# check if the region itself is progression
|
||||||
if region in region.multiworld.indirect_connections:
|
if region in region.multiworld.indirect_connections:
|
||||||
@@ -102,8 +103,7 @@ class EntranceLookup:
|
|||||||
and exit_ in self._usable_exits):
|
and exit_ in self._usable_exits):
|
||||||
self._expands_graph_cache[entrance] = True
|
self._expands_graph_cache[entrance] = True
|
||||||
return True
|
return True
|
||||||
elif exit_.connected_region and exit_.connected_region not in seen:
|
elif exit_.connected_region and exit_.connected_region not in visited:
|
||||||
seen.add(exit_.connected_region)
|
|
||||||
q.append(exit_.connected_region)
|
q.append(exit_.connected_region)
|
||||||
|
|
||||||
self._expands_graph_cache[entrance] = False
|
self._expands_graph_cache[entrance] = False
|
||||||
|
|||||||
14
kvui.py
14
kvui.py
@@ -720,11 +720,13 @@ class MessageBoxLabel(MDLabel):
|
|||||||
|
|
||||||
|
|
||||||
class MessageBox(Popup):
|
class MessageBox(Popup):
|
||||||
|
|
||||||
def __init__(self, title, text, error=False, **kwargs):
|
def __init__(self, title, text, error=False, **kwargs):
|
||||||
label = MessageBoxLabel(text=text, padding=("6dp", "0dp"))
|
label = MessageBoxLabel(text=text)
|
||||||
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
||||||
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
|
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
|
||||||
separator_color=separator_color, **kwargs)
|
separator_color=separator_color, **kwargs)
|
||||||
|
self.height += max(0, label.height - 18)
|
||||||
|
|
||||||
|
|
||||||
class MDNavigationItemBase(MDNavigationItem):
|
class MDNavigationItemBase(MDNavigationItem):
|
||||||
@@ -838,15 +840,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
|
||||||
@@ -1099,6 +1101,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):
|
||||||
|
|||||||
30
setup.py
30
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:
|
||||||
@@ -65,6 +65,7 @@ from Cython.Build import cythonize
|
|||||||
non_apworlds: set[str] = {
|
non_apworlds: set[str] = {
|
||||||
"A Link to the Past",
|
"A Link to the Past",
|
||||||
"Adventure",
|
"Adventure",
|
||||||
|
"ArchipIDLE",
|
||||||
"Archipelago",
|
"Archipelago",
|
||||||
"Lufia II Ancient Cave",
|
"Lufia II Ancient Cave",
|
||||||
"Meritous",
|
"Meritous",
|
||||||
@@ -371,7 +372,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] = []
|
||||||
@@ -380,35 +380,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"):
|
|
||||||
manifest = json.load(open(world_directory / "archipelago.json"))
|
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
3
test/TestBase.py
Normal file
3
test/TestBase.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .bases import TestBase, WorldTestBase
|
||||||
|
from warnings import warn
|
||||||
|
warn("TestBase was renamed to bases", DeprecationWarning)
|
||||||
@@ -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,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}",
|
||||||
|
|||||||
@@ -37,11 +37,10 @@ class TestImplemented(unittest.TestCase):
|
|||||||
|
|
||||||
def test_slot_data(self):
|
def test_slot_data(self):
|
||||||
"""Tests that if a world creates slot data, it's json serializable."""
|
"""Tests that if a world creates slot data, it's json serializable."""
|
||||||
# has an await for generate_output which isn't being called
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
excluded_games = ("Ocarina of Time",)
|
# has an await for generate_output which isn't being called
|
||||||
worlds_to_test = {game: world
|
if game_name in {"Ocarina of Time"}:
|
||||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
continue
|
||||||
for game_name, world_type in worlds_to_test.items():
|
|
||||||
multiworld = setup_solo_multiworld(world_type)
|
multiworld = setup_solo_multiworld(world_type)
|
||||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||||
distribute_items_restrictive(multiworld)
|
distribute_items_restrictive(multiworld)
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ class TestBase(unittest.TestCase):
|
|||||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||||
gen_steps = ("generate_early",)
|
gen_steps = ("generate_early",)
|
||||||
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||||
|
for game_name, world_type in worlds_to_test.items():
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||||
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_location_creation_steps(self):
|
def test_location_creation_steps(self):
|
||||||
"""Tests that Regions and Locations aren't created after `create_items`."""
|
"""Tests that Regions and Locations aren't created after `create_items`."""
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||||
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
worlds_to_test = {game: world
|
|
||||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
|
||||||
for game_name, world_type in worlds_to_test.items():
|
|
||||||
with self.subTest("Game", game_name=game_name):
|
with self.subTest("Game", game_name=game_name):
|
||||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||||
region_count = len(multiworld.get_regions())
|
region_count = len(multiworld.get_regions())
|
||||||
@@ -57,13 +54,13 @@ class TestBase(unittest.TestCase):
|
|||||||
call_all(multiworld, "generate_basic")
|
call_all(multiworld, "generate_basic")
|
||||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
f"{game_name} modified region count during generate_basic")
|
f"{game_name} modified region count during generate_basic")
|
||||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
|
||||||
f"{game_name} modified locations count during generate_basic")
|
f"{game_name} modified locations count during generate_basic")
|
||||||
|
|
||||||
call_all(multiworld, "pre_fill")
|
call_all(multiworld, "pre_fill")
|
||||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
f"{game_name} modified region count during pre_fill")
|
f"{game_name} modified region count during pre_fill")
|
||||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
|
||||||
f"{game_name} modified locations count during pre_fill")
|
f"{game_name} modified locations count during pre_fill")
|
||||||
|
|
||||||
def test_location_group(self):
|
def test_location_group(self):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,10 +22,6 @@ if TYPE_CHECKING:
|
|||||||
perf_logger = logging.getLogger("performance")
|
perf_logger = logging.getLogger("performance")
|
||||||
|
|
||||||
|
|
||||||
class InvalidItemError(KeyError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AutoWorldRegister(type):
|
class AutoWorldRegister(type):
|
||||||
world_types: Dict[str, Type[World]] = {}
|
world_types: Dict[str, Type[World]] = {}
|
||||||
__file__: str
|
__file__: str
|
||||||
@@ -75,10 +71,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 +333,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
|
||||||
@@ -228,6 +229,8 @@ components: List[Component] = [
|
|||||||
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
|
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
|
||||||
# ChecksFinder
|
# ChecksFinder
|
||||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||||
|
# Starcraft 2
|
||||||
|
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||||
# Zillion
|
# Zillion
|
||||||
Component('Zillion Client', 'ZillionClient',
|
Component('Zillion Client', 'ZillionClient',
|
||||||
file_identifier=SuffixIdentifier('.apzl')),
|
file_identifier=SuffixIdentifier('.apzl')),
|
||||||
@@ -242,66 +245,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")):
|
|
||||||
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
|
|
||||||
|
|
||||||
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,93 +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"):
|
|
||||||
manifest = json.load(open(os.path.join(dirpath, file), "r"))
|
|
||||||
break
|
|
||||||
if manifest:
|
|
||||||
break
|
|
||||||
game = manifest.get("game")
|
|
||||||
if game in AutoWorldRegister.world_types:
|
|
||||||
AutoWorldRegister.world_types[game].world_version = 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()},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GameData:
|
|||||||
"""
|
"""
|
||||||
:param data:
|
:param data:
|
||||||
"""
|
"""
|
||||||
self.abilities: Dict[int, AbilityData] = {a.ability_id: AbilityData(self, a) for a in data.abilities if a.available}
|
self.abilities: Dict[int, AbilityData] = {}
|
||||||
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
|
self.units: Dict[int, UnitTypeData] = {u.unit_id: UnitTypeData(self, u) for u in data.units if u.available}
|
||||||
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
|
self.upgrades: Dict[int, UpgradeData] = {u.upgrade_id: UpgradeData(self, u) for u in data.upgrades}
|
||||||
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
|
# Cached UnitTypeIds so that conversion does not take long. This needs to be moved elsewhere if a new GameData object is created multiple times per game
|
||||||
@@ -40,7 +40,7 @@ class AbilityData:
|
|||||||
self._proto = proto
|
self._proto = proto
|
||||||
|
|
||||||
# What happens if we comment this out? Should this not be commented out? What is its purpose?
|
# What happens if we comment this out? Should this not be commented out? What is its purpose?
|
||||||
# assert self.id != 0 # let the world burn
|
assert self.id != 0
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"AbilityData(name={self._proto.button_name})"
|
return f"AbilityData(name={self._proto.button_name})"
|
||||||
|
|||||||
@@ -623,23 +623,6 @@ class ParadeTrapWeight(Range):
|
|||||||
default = 20
|
default = 20
|
||||||
|
|
||||||
|
|
||||||
class DeathLinkAmnesty(Range):
|
|
||||||
"""Amount of forgiven deaths before sending a Death Link.
|
|
||||||
0 means that every death will send a Death Link."""
|
|
||||||
display_name = "Death Link Amnesty"
|
|
||||||
range_start = 0
|
|
||||||
range_end = 20
|
|
||||||
default = 0
|
|
||||||
|
|
||||||
|
|
||||||
class DWDeathLinkAmnesty(Range):
|
|
||||||
"""Amount of forgiven deaths before sending a Death Link during Death Wish levels."""
|
|
||||||
display_name = "Death Wish Amnesty"
|
|
||||||
range_start = 0
|
|
||||||
range_end = 30
|
|
||||||
default = 5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AHITOptions(PerGameCommonOptions):
|
class AHITOptions(PerGameCommonOptions):
|
||||||
start_inventory_from_pool: StartInventoryPool
|
start_inventory_from_pool: StartInventoryPool
|
||||||
@@ -717,8 +700,6 @@ class AHITOptions(PerGameCommonOptions):
|
|||||||
ParadeTrapWeight: ParadeTrapWeight
|
ParadeTrapWeight: ParadeTrapWeight
|
||||||
|
|
||||||
death_link: DeathLink
|
death_link: DeathLink
|
||||||
death_link_amnesty: DeathLinkAmnesty
|
|
||||||
dw_death_link_amnesty: DWDeathLinkAmnesty
|
|
||||||
|
|
||||||
|
|
||||||
ahit_option_groups: Dict[str, List[Any]] = {
|
ahit_option_groups: Dict[str, List[Any]] = {
|
||||||
@@ -788,6 +769,4 @@ slot_data_options: List[str] = [
|
|||||||
"MaxPonCost",
|
"MaxPonCost",
|
||||||
|
|
||||||
"death_link",
|
"death_link",
|
||||||
"death_link_amnesty",
|
|
||||||
"dw_death_link_amnesty",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
315
worlds/archipidle/Items.py
Normal file
315
worlds/archipidle/Items.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
item_table = (
|
||||||
|
'An Old GeoCities Profile',
|
||||||
|
'Very Funny Joke',
|
||||||
|
'Motivational Video',
|
||||||
|
'Staples Easy Button',
|
||||||
|
'One Million Dollars',
|
||||||
|
'Replica Master Sword',
|
||||||
|
'VHS Copy of Jurassic Park',
|
||||||
|
'32GB USB Drive',
|
||||||
|
'Pocket Protector',
|
||||||
|
'Leftover Parts from IKEA Furniture',
|
||||||
|
'Half-Empty Ink Cartridge for a Printer',
|
||||||
|
'Watch Battery',
|
||||||
|
'Towel',
|
||||||
|
'Scarf',
|
||||||
|
'2012 Magic the Gathering Core Set Starter Box',
|
||||||
|
'Poke\'mon Booster Pack',
|
||||||
|
'USB Speakers',
|
||||||
|
'Eco-Friendly Spork',
|
||||||
|
'Cheeseburger',
|
||||||
|
'Brand New Car',
|
||||||
|
'Hunting Knife',
|
||||||
|
'Zippo Lighter',
|
||||||
|
'Red Shirt',
|
||||||
|
'One-Up Mushroom',
|
||||||
|
'Nokia N-GAGE',
|
||||||
|
'2-Liter of Sprite',
|
||||||
|
'Free trial of the critically acclaimed MMORPG Final Fantasy XIV, including the entirety of A Realm Reborn and the award winning Heavensward and Stormblood expansions up to level 70 with no restrictions on playtime!',
|
||||||
|
'Can of Compressed Air',
|
||||||
|
'Striped Kitten',
|
||||||
|
'USB Power Adapter',
|
||||||
|
'Fortune Cookie',
|
||||||
|
'Nintendo Power Glove',
|
||||||
|
'The Lampshade of No Real Significance',
|
||||||
|
'Kneepads of Allure',
|
||||||
|
'Get Out of Jail Free Card',
|
||||||
|
'Box Set of Stargate SG-1 Season 4',
|
||||||
|
'The Missing Left Sock',
|
||||||
|
'Poster Tube',
|
||||||
|
'Electronic Picture Frame',
|
||||||
|
'Bottle of Shampoo',
|
||||||
|
'Your Mission, Should You Choose To Accept It',
|
||||||
|
'Fanny Pack',
|
||||||
|
'Robocop T-Shirt',
|
||||||
|
'Suspiciously Small Monocle',
|
||||||
|
'Table Saw',
|
||||||
|
'Cookies and Cream Milkshake',
|
||||||
|
'Deflated Accordion',
|
||||||
|
'Grandma\'s Homemade Pie',
|
||||||
|
'Invisible Lego on the Floor',
|
||||||
|
'Pitfall Trap',
|
||||||
|
'Flathead Screwdriver',
|
||||||
|
'Leftover Pizza',
|
||||||
|
'Voodoo Doll that Looks Like You',
|
||||||
|
'Pink Shoelaces',
|
||||||
|
'Half a Bottle of Scotch',
|
||||||
|
'Reminder Not to Forget Aginah',
|
||||||
|
'Medicine Ball',
|
||||||
|
'Yoga Mat',
|
||||||
|
'Chocolate Orange',
|
||||||
|
'Old Concert Tickets',
|
||||||
|
'The Pick of Destiny',
|
||||||
|
'McGuffin',
|
||||||
|
'Just a Regular McMuffin',
|
||||||
|
'34 Tacos',
|
||||||
|
'Duct Tape',
|
||||||
|
'Copy of Untitled Goose Game',
|
||||||
|
'Partially Used Bed Bath & Beyond Gift Card',
|
||||||
|
'Mostly Popped Bubble Wrap',
|
||||||
|
'Expired Driver\'s License',
|
||||||
|
'The Look, You Know the One',
|
||||||
|
'Transformers Lunch Box',
|
||||||
|
'MP3 Player',
|
||||||
|
'Dry Sharpie',
|
||||||
|
'Chalkboard Eraser',
|
||||||
|
'Overhead Projector',
|
||||||
|
'Physical Copy of the Japanese 1.0 Link to the Past',
|
||||||
|
'Collectable Action Figure',
|
||||||
|
'Box Set of The Lord of the Rings Books',
|
||||||
|
'Lite-Bright',
|
||||||
|
'Stories from the Good-Old-Days',
|
||||||
|
'Un-Reproducable Bug Reports',
|
||||||
|
'Autographed Copy of Shaq-Fu',
|
||||||
|
'Game-Winning Baseball',
|
||||||
|
'Portable Battery Bank',
|
||||||
|
'Blockbuster Membership Card',
|
||||||
|
'Offensive Bumper Sticker',
|
||||||
|
'Last Sunday\'s Crossword Puzzle',
|
||||||
|
'Rubik\'s Cube',
|
||||||
|
'Your First Grey Hair',
|
||||||
|
'Embarrassing Childhood Photo',
|
||||||
|
'Abandoned Sphere One Check',
|
||||||
|
'The Internet',
|
||||||
|
'Late-Night Cartoons',
|
||||||
|
'The Correct Usage of a Semicolon',
|
||||||
|
'Microsoft Windows 95 Resource Kit',
|
||||||
|
'Car-Phone',
|
||||||
|
'Walkman Radio',
|
||||||
|
'Relevant XKCD Comic',
|
||||||
|
'Razor Scooter',
|
||||||
|
'Set of Beyblades',
|
||||||
|
'Box of Pogs',
|
||||||
|
'Beanie-Baby Collection',
|
||||||
|
'Laser Tag Gun',
|
||||||
|
'Radio Controlled Car',
|
||||||
|
'Boogie Board',
|
||||||
|
'Air Jordans',
|
||||||
|
'Rubber Duckie',
|
||||||
|
'The Last Cookie in the Cookie Jar',
|
||||||
|
'Tin-Foil Hat',
|
||||||
|
'Button-Up Shirt',
|
||||||
|
'Designer Brand Bag',
|
||||||
|
'Trapper Keeper',
|
||||||
|
'Fake Moustache',
|
||||||
|
'Colored Pencils',
|
||||||
|
'Pair of 3D Glasses',
|
||||||
|
'Pair of Movie Tickets',
|
||||||
|
'Refrigerator Magnets',
|
||||||
|
'NASCAR Dinner Plates',
|
||||||
|
'The Final Boss',
|
||||||
|
'Unskippable Cutscenes',
|
||||||
|
'24 Rolls of Toilet Paper',
|
||||||
|
'Canned Soup',
|
||||||
|
'Warm Blanket',
|
||||||
|
'3D Printer',
|
||||||
|
'Jetpack',
|
||||||
|
'Hoverboard',
|
||||||
|
'Joycons with No Drift',
|
||||||
|
'Double Rainbow',
|
||||||
|
'Ping Pong Ball',
|
||||||
|
'Area 51 Arcade Cabinet',
|
||||||
|
'Elephant in the Room',
|
||||||
|
'The Pink Panther',
|
||||||
|
'Denim Shorts',
|
||||||
|
'Tennis Racket',
|
||||||
|
'Collection of Stuffed Animals',
|
||||||
|
'Old Cell Phone',
|
||||||
|
'Nintendo Virtual Boy',
|
||||||
|
'Box of 5.25 Inch Floppy Disks',
|
||||||
|
'Bag of Miscellaneous Wires',
|
||||||
|
'Garden Shovel',
|
||||||
|
'Leather Gloves',
|
||||||
|
'Knife of +9 VS Ogres',
|
||||||
|
'Old, Smelly Cheese',
|
||||||
|
'Linksys BEFSR41 Router',
|
||||||
|
'Ethernet Cables for a LAN Party',
|
||||||
|
'Mechanical Pencil',
|
||||||
|
'Book of Graph Paper',
|
||||||
|
'300 Sheets of Printer Paper',
|
||||||
|
'One AAA Battery',
|
||||||
|
'Box of Old Game Controllers',
|
||||||
|
'Sega Dreamcast',
|
||||||
|
'Mario\'s Overalls',
|
||||||
|
'Betamax Player',
|
||||||
|
'Stray Lego',
|
||||||
|
'Chocolate Chip Pancakes',
|
||||||
|
'Two Blueberry Muffins',
|
||||||
|
'Nintendo 64 Controller with a Perfect Thumbstick',
|
||||||
|
'Cuckoo Crossing the Road',
|
||||||
|
'One Eyed, One Horned, Flying Purple People-Eater',
|
||||||
|
'Love Potion Number Nine',
|
||||||
|
'Wireless Headphones',
|
||||||
|
'Festive Keychain',
|
||||||
|
'Bundle of Twisted Cables',
|
||||||
|
'Plank of Wood',
|
||||||
|
'Broken Ant Farm',
|
||||||
|
'Thirty-six American Dollars',
|
||||||
|
'Can of Shaving Cream',
|
||||||
|
'Blue Hair Dye',
|
||||||
|
'Mug Engraved with the AP Logo',
|
||||||
|
'Tube of Toothpaste',
|
||||||
|
'Album of Elevator Music',
|
||||||
|
'Headlight Fluid',
|
||||||
|
'Tickets to the Renaissance Faire',
|
||||||
|
'Bag of Golf Balls',
|
||||||
|
'Box of Packing Peanuts',
|
||||||
|
'Bottle of Peanut Butter',
|
||||||
|
'Breath of the Wild Cookbook',
|
||||||
|
'Stardew Valley Cookbook',
|
||||||
|
'Thirteen Angry Chickens',
|
||||||
|
'Bowl of Cereal',
|
||||||
|
'Rubber Snake',
|
||||||
|
'Stale Sunflower Seeds',
|
||||||
|
'Alarm Clock Without a Snooze Button',
|
||||||
|
'Wet Pineapple',
|
||||||
|
'Set of Scented Candles',
|
||||||
|
'Adorable Stuffed Animal',
|
||||||
|
'The Broodwitch',
|
||||||
|
'Old Photo Album',
|
||||||
|
'Trade Quest Item',
|
||||||
|
'Pair of Fancy Boots',
|
||||||
|
'Shoddy Pickaxe',
|
||||||
|
'Adventurer\'s Sword',
|
||||||
|
'Cute Puppy',
|
||||||
|
'Box of Matches',
|
||||||
|
'Set of Allen Wrenches',
|
||||||
|
'Glass of Water',
|
||||||
|
'Magic Shaggy Carpet',
|
||||||
|
'Macaroni and Cheese',
|
||||||
|
'Chocolate Chip Cookie Dough Ice Cream',
|
||||||
|
'Fresh Strawberries',
|
||||||
|
'Delicious Tacos',
|
||||||
|
'The Krabby Patty Recipe',
|
||||||
|
'Map to Waldo\'s Location',
|
||||||
|
'Stray Cat',
|
||||||
|
'Ham and Cheese Sandwich',
|
||||||
|
'DVD Player',
|
||||||
|
'Motorcycle Helmet',
|
||||||
|
'Fake Flowers',
|
||||||
|
'6-Pack of Sponges',
|
||||||
|
'Heated Pants',
|
||||||
|
'Empty Glass Bottle',
|
||||||
|
'Brown Paper Bag',
|
||||||
|
'Model Train Set',
|
||||||
|
'TV Remote',
|
||||||
|
'RC Car',
|
||||||
|
'Super Soaker 9000',
|
||||||
|
'Giant Sunglasses',
|
||||||
|
'World\'s Smallest Violin',
|
||||||
|
'Pile of Fresh Warm Laundry',
|
||||||
|
'Half-Empty Ice Cube Tray',
|
||||||
|
'Bob Ross Afro Wig',
|
||||||
|
'Empty Cardboard Box',
|
||||||
|
'Packet of Soy Sauce',
|
||||||
|
'Solutions to a Math Test',
|
||||||
|
'Pencil Eraser',
|
||||||
|
'The Great Pumpkin',
|
||||||
|
'Very Expensive Toaster',
|
||||||
|
'Pack of Colored Sharpies',
|
||||||
|
'Bag of Chocolate Chips',
|
||||||
|
'Grandma\'s Homemade Cookies',
|
||||||
|
'Collection of Bottle Caps',
|
||||||
|
'Pack of Playing Cards',
|
||||||
|
'Boom Box',
|
||||||
|
'Toy Sail Boat',
|
||||||
|
'Smooth Nail File',
|
||||||
|
'Colored Chalk',
|
||||||
|
'Missing Button',
|
||||||
|
'Rubber Band Ball',
|
||||||
|
'Joystick',
|
||||||
|
'Galaga Arcade Cabinet',
|
||||||
|
'Anime Mouse Pad',
|
||||||
|
'Orange and Yellow Glow Sticks',
|
||||||
|
'Odd Bookmark',
|
||||||
|
'Stray Dice',
|
||||||
|
'Tooth Picks',
|
||||||
|
'Dirty Dishes',
|
||||||
|
'Poke\'mon Card Game Rule Book (Gen 1)',
|
||||||
|
'Salt Shaker',
|
||||||
|
'Digital Thermometer',
|
||||||
|
'Infinite Improbability Drive',
|
||||||
|
'Fire Extinguisher',
|
||||||
|
'Beeping Smoke Alarm',
|
||||||
|
'Greasy Spatula',
|
||||||
|
'Progressive Auto Insurance',
|
||||||
|
'Mace Windu\'s Purple Lightsaber',
|
||||||
|
'An Old Fixer-Upper',
|
||||||
|
'Gamer Chair',
|
||||||
|
'Comfortable Reclining Chair',
|
||||||
|
'Shirt Covered in Dog Hair',
|
||||||
|
'Angry Praying Mantis',
|
||||||
|
'Card Games on Motorcycles',
|
||||||
|
'Trucker Hat',
|
||||||
|
'The DK Rap',
|
||||||
|
'Three Great Balls',
|
||||||
|
'Some Very Sus Behavior',
|
||||||
|
'Glass of Orange Juice',
|
||||||
|
'Turkey Bacon',
|
||||||
|
'Bald Barbie Doll',
|
||||||
|
'Developer Commentary',
|
||||||
|
'Subscription to Nintendo Power Magazine',
|
||||||
|
'DeLorean Time Machine',
|
||||||
|
'Unkillable Cockroach',
|
||||||
|
'Dungeons & Dragons Rulebook',
|
||||||
|
'Boxed Copy of Quest 64',
|
||||||
|
'James Bond\'s Gadget Wristwatch',
|
||||||
|
'Tube of Go-Gurt',
|
||||||
|
'Digital Watch',
|
||||||
|
'Laser Pointer',
|
||||||
|
'The Secret Cow Level',
|
||||||
|
'AOL Free Trial CD-ROM',
|
||||||
|
'E.T. for Atari 2600',
|
||||||
|
'Season 2 of Knight Rider',
|
||||||
|
'Spam E-Mails',
|
||||||
|
'Half-Life 3 Release Date',
|
||||||
|
'Source Code of Jurassic Park',
|
||||||
|
'Moldy Cheese',
|
||||||
|
'Comic Book Collection',
|
||||||
|
'Hardcover Copy of Scott Pilgrim VS the World',
|
||||||
|
'Old Gym Shorts',
|
||||||
|
'Very Cool Sunglasses',
|
||||||
|
'Your High School Yearbook Picture',
|
||||||
|
'Written Invitation to Prom',
|
||||||
|
'The Star Wars Holiday Special',
|
||||||
|
'Oil Change Coupon',
|
||||||
|
'Finger Guns',
|
||||||
|
'Box of Tabletop Games',
|
||||||
|
'Sock Puppets',
|
||||||
|
'The Dog of Wisdom',
|
||||||
|
'Surprised Chipmunk',
|
||||||
|
'Stonks',
|
||||||
|
'A Shrubbery',
|
||||||
|
'Roomba with a Knife',
|
||||||
|
'Wet Cat',
|
||||||
|
'The missing moderator, Frostwares',
|
||||||
|
'1,793 Crossbows',
|
||||||
|
'Holographic First Edition Charizard (Gen 1)',
|
||||||
|
'VR Headset',
|
||||||
|
'Archipelago 1.0 Release Date',
|
||||||
|
'Strand of Galadriel\'s Hair',
|
||||||
|
'Can of Meow-Mix',
|
||||||
|
'Shake-Weight',
|
||||||
|
'DVD Collection of Billy Mays Infomercials',
|
||||||
|
'Old CD Key',
|
||||||
|
)
|
||||||
28
worlds/archipidle/Rules.py
Normal file
28
worlds/archipidle/Rules.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from BaseClasses import MultiWorld
|
||||||
|
from worlds.AutoWorld import LogicMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipIDLELogic(LogicMixin):
|
||||||
|
def _archipidle_location_is_accessible(self, player_id, items_required):
|
||||||
|
return sum(self.prog_items[player_id].values()) >= items_required
|
||||||
|
|
||||||
|
|
||||||
|
def set_rules(world: MultiWorld, player: int):
|
||||||
|
for i in range(16, 31):
|
||||||
|
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
||||||
|
state: state._archipidle_location_is_accessible(player, 4)
|
||||||
|
|
||||||
|
for i in range(31, 51):
|
||||||
|
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
||||||
|
state: state._archipidle_location_is_accessible(player, 10)
|
||||||
|
|
||||||
|
for i in range(51, 101):
|
||||||
|
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
||||||
|
state: state._archipidle_location_is_accessible(player, 20)
|
||||||
|
|
||||||
|
for i in range(101, 201):
|
||||||
|
world.get_location(f"IDLE item number {i}", player).access_rule = lambda \
|
||||||
|
state: state._archipidle_location_is_accessible(player, 40)
|
||||||
|
|
||||||
|
world.completion_condition[player] =\
|
||||||
|
lambda state: state.can_reach(world.get_location("IDLE item number 200", player), "Location", player)
|
||||||
128
worlds/archipidle/__init__.py
Normal file
128
worlds/archipidle/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification
|
||||||
|
from worlds.AutoWorld import World, WebWorld
|
||||||
|
from datetime import datetime
|
||||||
|
from .Items import item_table
|
||||||
|
from .Rules import set_rules
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipIDLEWebWorld(WebWorld):
|
||||||
|
theme = 'partyTime'
|
||||||
|
tutorials = [
|
||||||
|
Tutorial(
|
||||||
|
tutorial_name='Setup Guide',
|
||||||
|
description='A guide to playing Archipidle',
|
||||||
|
language='English',
|
||||||
|
file_name='guide_en.md',
|
||||||
|
link='guide/en',
|
||||||
|
authors=['Farrak Kilhn']
|
||||||
|
),
|
||||||
|
Tutorial(
|
||||||
|
tutorial_name='Guide d installation',
|
||||||
|
description='Un guide pour jouer à Archipidle',
|
||||||
|
language='Français',
|
||||||
|
file_name='guide_fr.md',
|
||||||
|
link='guide/fr',
|
||||||
|
authors=['TheLynk']
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipIDLEWorld(World):
|
||||||
|
"""
|
||||||
|
An idle game which sends a check every thirty to sixty seconds, up to two hundred checks.
|
||||||
|
"""
|
||||||
|
game = "ArchipIDLE"
|
||||||
|
topology_present = False
|
||||||
|
hidden = (datetime.now().month != 4) # ArchipIDLE is only visible during April
|
||||||
|
web = ArchipIDLEWebWorld()
|
||||||
|
|
||||||
|
item_name_to_id = {}
|
||||||
|
start_id = 9000
|
||||||
|
for item in item_table:
|
||||||
|
item_name_to_id[item] = start_id
|
||||||
|
start_id += 1
|
||||||
|
|
||||||
|
location_name_to_id = {}
|
||||||
|
start_id = 9000
|
||||||
|
for i in range(1, 201):
|
||||||
|
location_name_to_id[f"IDLE item number {i}"] = start_id
|
||||||
|
start_id += 1
|
||||||
|
|
||||||
|
def set_rules(self):
|
||||||
|
set_rules(self.multiworld, self.player)
|
||||||
|
|
||||||
|
def create_item(self, name: str) -> Item:
|
||||||
|
return Item(name, ItemClassification.progression, self.item_name_to_id[name], self.player)
|
||||||
|
|
||||||
|
def create_items(self):
|
||||||
|
item_pool = [
|
||||||
|
ArchipIDLEItem(
|
||||||
|
item_table[0],
|
||||||
|
ItemClassification.progression,
|
||||||
|
self.item_name_to_id[item_table[0]],
|
||||||
|
self.player
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for i in range(40):
|
||||||
|
item_pool.append(ArchipIDLEItem(
|
||||||
|
item_table[1],
|
||||||
|
ItemClassification.progression,
|
||||||
|
self.item_name_to_id[item_table[1]],
|
||||||
|
self.player
|
||||||
|
))
|
||||||
|
|
||||||
|
for i in range(40):
|
||||||
|
item_pool.append(ArchipIDLEItem(
|
||||||
|
item_table[2],
|
||||||
|
ItemClassification.filler,
|
||||||
|
self.item_name_to_id[item_table[2]],
|
||||||
|
self.player
|
||||||
|
))
|
||||||
|
|
||||||
|
item_table_copy = list(item_table[3:])
|
||||||
|
self.random.shuffle(item_table_copy)
|
||||||
|
for i in range(119):
|
||||||
|
item_pool.append(ArchipIDLEItem(
|
||||||
|
item_table_copy[i],
|
||||||
|
ItemClassification.progression if i < 9 else ItemClassification.filler,
|
||||||
|
self.item_name_to_id[item_table_copy[i]],
|
||||||
|
self.player
|
||||||
|
))
|
||||||
|
|
||||||
|
self.multiworld.itempool += item_pool
|
||||||
|
|
||||||
|
def create_regions(self):
|
||||||
|
self.multiworld.regions += [
|
||||||
|
create_region(self.multiworld, self.player, 'Menu', None, ['Entrance to IDLE Zone']),
|
||||||
|
create_region(self.multiworld, self.player, 'IDLE Zone', self.location_name_to_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
# link up our region with the entrance we just made
|
||||||
|
self.multiworld.get_entrance('Entrance to IDLE Zone', self.player)\
|
||||||
|
.connect(self.multiworld.get_region('IDLE Zone', self.player))
|
||||||
|
|
||||||
|
def get_filler_item_name(self) -> str:
|
||||||
|
return self.multiworld.random.choice(item_table)
|
||||||
|
|
||||||
|
|
||||||
|
def create_region(world: MultiWorld, player: int, name: str, locations=None, exits=None):
|
||||||
|
region = Region(name, player, world)
|
||||||
|
if locations:
|
||||||
|
for location_name in locations.keys():
|
||||||
|
location = ArchipIDLELocation(player, location_name, locations[location_name], region)
|
||||||
|
region.locations.append(location)
|
||||||
|
|
||||||
|
if exits:
|
||||||
|
for _exit in exits:
|
||||||
|
region.exits.append(Entrance(player, _exit, region))
|
||||||
|
|
||||||
|
return region
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipIDLEItem(Item):
|
||||||
|
game = "ArchipIDLE"
|
||||||
|
|
||||||
|
|
||||||
|
class ArchipIDLELocation(Location):
|
||||||
|
game: str = "ArchipIDLE"
|
||||||
13
worlds/archipidle/docs/en_ArchipIDLE.md
Normal file
13
worlds/archipidle/docs/en_ArchipIDLE.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ArchipIDLE
|
||||||
|
|
||||||
|
## What is this game?
|
||||||
|
|
||||||
|
ArchipIDLE was originally the 2022 Archipelago April Fools' Day joke. It is an idle game that sends a location check
|
||||||
|
on regular intervals. Updated annually with more items, gimmicks, and features, the game is visible
|
||||||
|
only during the month of April.
|
||||||
|
|
||||||
|
## Where is the options page?
|
||||||
|
|
||||||
|
The [player options page for this game](../player-options) contains all the options you need to configure
|
||||||
|
and export a config file.
|
||||||
|
|
||||||
12
worlds/archipidle/docs/guide_en.md
Normal file
12
worlds/archipidle/docs/guide_en.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# ArchipIdle Setup Guide
|
||||||
|
|
||||||
|
## Joining a MultiWorld Game
|
||||||
|
1. Generate a `.yaml` file from the [ArchipIDLE Player Options Page](/games/ArchipIDLE/player-options)
|
||||||
|
2. Open the ArchipIDLE Client in your web browser by either:
|
||||||
|
- Navigate to the [ArchipIDLE Client](http://idle.multiworld.link)
|
||||||
|
- Download the client and run it locally from the
|
||||||
|
[ArchipIDLE GitHub Releases Page](https://github.com/ArchipelagoMW/archipidle/releases)
|
||||||
|
3. Enter the server address in the `Server Address` field and press enter
|
||||||
|
4. Enter your slot name when prompted. This should be the same as the `name` you entered on the
|
||||||
|
options page above, or the `name` field in your yaml file.
|
||||||
|
5. Click the "Begin!" button.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user