mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-10-28 10:53:12 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316444cc5a | ||
|
|
7971961166 | ||
|
|
9246bd9541 | ||
|
|
30fa0658b0 | ||
|
|
44a0c44036 |
No files matched your search
+1
-1
@@ -46,11 +46,11 @@ dist
|
||||
/prof/
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/sni-*/
|
||||
/appimagetool*
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"../test/programs/test_multi_server.py",
|
||||
"../test/utils/__init__.py",
|
||||
"../test/webhost/test_descriptions.py",
|
||||
"../test/webhost/test_suuid.py",
|
||||
"../worlds/AutoSNIClient.py",
|
||||
"type_check.py"
|
||||
],
|
||||
|
||||
+21
-124
@@ -11,146 +11,43 @@ on:
|
||||
- "!.github/workflows/**"
|
||||
- ".github/workflows/docker.yml"
|
||||
branches:
|
||||
- "main"
|
||||
- "dock-dev"
|
||||
tags:
|
||||
- "v?[0-9]+.[0-9]+.[0-9]*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
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/[email protected]
|
||||
|
||||
- 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/[email protected]
|
||||
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
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_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: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ubufugu/dockipelago
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/[email protected]
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
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
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
with:
|
||||
draft: true # don't publish right away, especially since windows build is added by hand
|
||||
prerelease: false
|
||||
@@ -97,15 +97,13 @@ jobs:
|
||||
build/exe.*/ArchipelagoServer.exe
|
||||
setups/*
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
setups/*
|
||||
fail_on_unmatched_files: true
|
||||
overwrite_files: false # Windows release is usually built by hand
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -167,14 +165,12 @@ jobs:
|
||||
build/exe.*/ArchipelagoServer
|
||||
dist/*
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
dist/*
|
||||
fail_on_unmatched_files: true
|
||||
overwrite_files: false # should never happen; avoids accidentally changing a release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -45,8 +45,11 @@ EnemizerCLI/
|
||||
/SNI/
|
||||
/sni-*/
|
||||
/appimagetool*
|
||||
<<<<<<< Updated upstream
|
||||
/VC_redist.x64.exe
|
||||
/host.yaml
|
||||
=======
|
||||
>>>>>>> Stashed changes
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="APQuest Tests" type="tests" factoryName="py.test">
|
||||
<module name="Archipelago" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="AP_TEST_WORLDS" value="apquest" />
|
||||
</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="DEBUG_JUST_MY_CODE" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="RUN_TOOL" value="" />
|
||||
<option name="_new_keywords" value="""" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value=""$PROJECT_DIR$/worlds -q --continue-on-collection-errors"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/test"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
+13
-21
@@ -8,10 +8,10 @@ import secrets
|
||||
import warnings
|
||||
from argparse import Namespace
|
||||
from collections import Counter, deque, defaultdict
|
||||
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set as AbstractSet
|
||||
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (Any, ClassVar, Dict, List, Literal, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, overload)
|
||||
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
|
||||
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
|
||||
import dataclasses
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
@@ -49,7 +49,7 @@ class ThreadBarrierProxy:
|
||||
return getattr(self.obj, name)
|
||||
else:
|
||||
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
|
||||
"Please use world.random or randomize ahead of output.")
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
|
||||
|
||||
class HasNameAndPlayer(Protocol):
|
||||
@@ -100,6 +100,8 @@ class MultiWorld():
|
||||
game: Dict[int, str]
|
||||
|
||||
random: random.Random
|
||||
per_slot_randoms: Utils.DeprecateDict[int, random.Random]
|
||||
"""Deprecated. Please use `self.random` instead."""
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -175,6 +177,8 @@ class MultiWorld():
|
||||
set_player_attr('game', "Archipelago")
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)", True)
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
@@ -510,7 +514,7 @@ class MultiWorld():
|
||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||
|
||||
def get_locations(self, player: int | None = None) -> Collection[Location]:
|
||||
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
|
||||
if player is not None:
|
||||
return self.regions.location_cache[player].values()
|
||||
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
|
||||
@@ -1034,8 +1038,6 @@ class CollectionState():
|
||||
|
||||
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
|
||||
if count <= 0:
|
||||
return True
|
||||
found: int = 0
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item_name in items:
|
||||
@@ -1047,8 +1049,6 @@ class CollectionState():
|
||||
def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
|
||||
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
|
||||
Ignores duplicates of the same item."""
|
||||
if count <= 0:
|
||||
return True
|
||||
found: int = 0
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item_name in items:
|
||||
@@ -1077,8 +1077,6 @@ class CollectionState():
|
||||
# item name group related
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
"""Returns True if the state contains at least `count` items present in a specified item group."""
|
||||
if count <= 0:
|
||||
return True
|
||||
found: int = 0
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||
@@ -1091,8 +1089,6 @@ class CollectionState():
|
||||
"""Returns True if the state contains at least `count` items present in a specified item group.
|
||||
Ignores duplicates of the same item.
|
||||
"""
|
||||
if count <= 0:
|
||||
return True
|
||||
found: int = 0
|
||||
player_prog_items = self.prog_items[player]
|
||||
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
|
||||
@@ -1486,8 +1482,6 @@ class Location:
|
||||
parent_region: Optional[Region]
|
||||
locked: bool = False
|
||||
show_in_spoiler: bool = True
|
||||
progression_balancing_sphere: bool = True
|
||||
"""If false, progression at this location does not start a new sphere during progression balancing."""
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
||||
@@ -1565,14 +1559,12 @@ class ItemClassification(IntFlag):
|
||||
""" Item that is detrimental in some way. """
|
||||
|
||||
skip_balancing = 0b01000
|
||||
"""Item that progression balancing should not move.
|
||||
""" should technically never occur on its own
|
||||
Item that is logically relevant, but progression balancing should not touch.
|
||||
|
||||
Possible reasons for why an item should not be pulled ahead by progression balancing:
|
||||
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
|
||||
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items)
|
||||
|
||||
Non-progression items can also use this flag to prevent them from being displaced into a later sphere by a
|
||||
progression item. """
|
||||
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
|
||||
|
||||
deprioritized = 0b10000
|
||||
""" Should technically never occur on its own.
|
||||
@@ -1625,7 +1617,7 @@ class Item:
|
||||
|
||||
@property
|
||||
def skip_in_prog_balancing(self) -> bool:
|
||||
return ItemClassification.skip_balancing in self.classification
|
||||
return ItemClassification.progression_skip_balancing in self.classification
|
||||
|
||||
@property
|
||||
def useful(self) -> bool:
|
||||
|
||||
+4
-41
@@ -341,10 +341,6 @@ class CommonContext:
|
||||
"""Name used in Connect packet"""
|
||||
seed_name: str | None
|
||||
"""Seed name that will be validated on opening a socket if present"""
|
||||
server_seed_name: str | None
|
||||
"""Actual seed_name reported by the server in RoomInfo"""
|
||||
connected_identity: tuple[str, int, int] | None
|
||||
"""(server_seed_name, team, slot) of the last successful connection, used to detect a session switch"""
|
||||
|
||||
# locations
|
||||
locations_checked: set[int]
|
||||
@@ -403,10 +399,6 @@ class CommonContext:
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
self.server_seed_name = None
|
||||
self.connected_identity = None
|
||||
# decouple from the class-level mutable set so tag changes (e.g. DeathLink) don't leak across instances
|
||||
self.tags = set(type(self).tags)
|
||||
|
||||
self.locations_checked = set() # local state
|
||||
self.locations_scouted = set()
|
||||
@@ -467,7 +459,6 @@ class CommonContext:
|
||||
self.team = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_seed_name = None
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.generator_version = Version(0, 0, 0)
|
||||
self.server = None
|
||||
@@ -479,21 +470,6 @@ class CommonContext:
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
def reset_session_state(self):
|
||||
"""Clear local progress and data-storage state tied to a single session."""
|
||||
self.locations_checked = set()
|
||||
self.locations_scouted = set()
|
||||
self.locations_info = {}
|
||||
self.items_received = []
|
||||
self.missing_locations = set()
|
||||
self.checked_locations = set()
|
||||
self.server_locations = set()
|
||||
self.finished_game = False
|
||||
self.ready = False
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_keys = set()
|
||||
self.current_energy_link_value = None
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
if not allow_autoreconnect:
|
||||
self.disconnected_intentionally = True
|
||||
@@ -948,7 +924,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.exception(f"Could not get command from {args}")
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
ctx.server_seed_name = args["seed_name"]
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
@@ -1029,11 +1004,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.username = ctx.auth
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# on a switch to a different session, clear session state before stale checks/goal are replayed below
|
||||
identity = (ctx.server_seed_name, ctx.team, ctx.slot)
|
||||
if ctx.connected_identity is not None and identity != ctx.connected_identity:
|
||||
ctx.reset_session_state()
|
||||
ctx.connected_identity = identity
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
|
||||
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
|
||||
@@ -1099,7 +1069,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if "players" in args:
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args["hint_points"]
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
checked = set(args["checked_locations"])
|
||||
ctx.checked_locations |= checked
|
||||
@@ -1107,10 +1077,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
|
||||
# Update hint info for local display
|
||||
if "hint_cost" in args:
|
||||
ctx.hint_cost = int(args["hint_cost"])
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
|
||||
@@ -1122,12 +1088,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been sent this
|
||||
if "DeathLink" in tags:
|
||||
data = args.get("data", {})
|
||||
time = data.get("time")
|
||||
if time is not None and ctx.last_death_link != time:
|
||||
ctx.on_deathlink(args["data"])
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
elif cmd == "Retrieved":
|
||||
ctx.stored_data.update(args["keys"])
|
||||
|
||||
+33
@@ -1,5 +1,23 @@
|
||||
# hadolint global ignore=SC1090,SC1091
|
||||
|
||||
# Source
|
||||
FROM scratch AS release
|
||||
WORKDIR /release
|
||||
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
|
||||
|
||||
# Enemizer
|
||||
FROM alpine:3.21 AS enemizer
|
||||
ARG TARGETARCH
|
||||
WORKDIR /release
|
||||
COPY --from=release /release/Enemizer.zip .
|
||||
|
||||
# No release for arm architecture. Skip.
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
apk add unzip=6.0-r15 --no-cache && \
|
||||
unzip -u Enemizer.zip -d EnemizerCLI && \
|
||||
chmod -R 777 EnemizerCLI; \
|
||||
else touch EnemizerCLI; fi
|
||||
|
||||
# Cython builder stage
|
||||
FROM python:3.12 AS cython-builder
|
||||
|
||||
@@ -63,6 +81,15 @@ RUN apt-get purge -y \
|
||||
g++ && \
|
||||
apt-get autoremove -y
|
||||
|
||||
# Copy necessary components
|
||||
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
|
||||
|
||||
# No release for arm architecture. Skip.
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
cp -r /tmp/EnemizerCLI EnemizerCLI; \
|
||||
fi; \
|
||||
rm -rf /tmp/EnemizerCLI
|
||||
|
||||
# Define health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-80} || exit 1
|
||||
@@ -70,4 +97,10 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
# Ensure no runtime ModuleUpdate.
|
||||
ENV SKIP_REQUIREMENTS_UPDATE=true
|
||||
|
||||
# Port range for Archipelago rooms. I choose only ports 49152-49162
|
||||
ARG MAX_PORT=49162
|
||||
|
||||
RUN sed -i "s/65535/${MAX_PORT}/" WebHostLib/customserver.py
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT [ "python", "WebHost.py" ]
|
||||
@@ -1,8 +1,8 @@
|
||||
from collections import Counter, defaultdict, deque
|
||||
from collections.abc import Iterable, Sequence
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Callable, Literal
|
||||
import typing
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
||||
from Options import Accessibility
|
||||
@@ -12,11 +12,10 @@ from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
def __init__(self, *args: str | object, **kwargs: object) -> None:
|
||||
multiworld = kwargs.get("multiworld")
|
||||
if isinstance(multiworld, MultiWorld) and isinstance(args[0], str):
|
||||
placements = (args[0] + "\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in multiworld.get_filled_locations()]}")
|
||||
def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
|
||||
if "multiworld" in kwargs and isinstance(args[0], str):
|
||||
placements = (args[0] + f"\nAll Placements:\n" +
|
||||
f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
|
||||
args = (placements, *args[1:])
|
||||
super().__init__(*args)
|
||||
|
||||
@@ -25,9 +24,8 @@ def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
|
||||
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
|
||||
|
||||
|
||||
def sweep_from_pool(base_state: CollectionState,
|
||||
itempool: Sequence[Item] = (),
|
||||
locations: Iterable[Location] | None = None) -> CollectionState:
|
||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
|
||||
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
@@ -35,9 +33,9 @@ def sweep_from_pool(base_state: CollectionState,
|
||||
return new_state
|
||||
|
||||
|
||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: list[Location],
|
||||
item_pool: list[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: Callable[[Location], None] | None = None,
|
||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
||||
name: str = "Unknown") -> None:
|
||||
"""
|
||||
@@ -53,11 +51,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
||||
:param name: name of this fill step for progress logging purposes
|
||||
"""
|
||||
unplaced_items: list[Item] = []
|
||||
placements: list[Location] = []
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
cleanup_required = False
|
||||
swapped_items: Counter[tuple[int, str, bool]] = Counter()
|
||||
reachable_items: dict[int, deque[Item]] = {}
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
|
||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||
for item in item_pool:
|
||||
reachable_items.setdefault(item.player, deque()).append(item)
|
||||
|
||||
@@ -66,7 +64,6 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
placed = 0
|
||||
|
||||
while any(reachable_items.values()) and locations:
|
||||
items_to_place: list[Item]
|
||||
if one_item_per_player:
|
||||
# grab one item per player
|
||||
items_to_place = [items.pop()
|
||||
@@ -98,15 +95,13 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
break
|
||||
item_to_place = items_to_place.pop(0)
|
||||
|
||||
spot_to_fill: Location | None = None
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||
perform_access_check = (
|
||||
(not multiworld.has_beaten_game(maximum_exploration_state, item_to_place.player))
|
||||
if single_player_placement
|
||||
else not has_beaten_game
|
||||
)
|
||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) \
|
||||
if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
perform_access_check = True
|
||||
|
||||
@@ -123,7 +118,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
if swap:
|
||||
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
||||
# swap state, instead of sweeping from `base_state` each time.
|
||||
previous_safe_swap_state_cache: deque[CollectionState] = deque()
|
||||
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
|
||||
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
||||
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
||||
max_swap_base_state_cache_length = 3
|
||||
@@ -220,10 +215,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
base_state, [], multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
for placement in placements:
|
||||
if (
|
||||
multiworld.worlds[placement.item.player].options.accessibility != "minimal" and
|
||||
not placement.can_reach(state)
|
||||
):
|
||||
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
|
||||
placement.item.location = None
|
||||
unplaced_items.append(placement.item)
|
||||
placement.item = None
|
||||
@@ -263,14 +255,14 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
|
||||
|
||||
def remaining_fill(multiworld: MultiWorld,
|
||||
locations: list[Location],
|
||||
itempool: list[Item],
|
||||
name: str = "Remaining",
|
||||
locations: typing.List[Location],
|
||||
itempool: typing.List[Item],
|
||||
name: str = "Remaining",
|
||||
move_unplaceable_to_start_inventory: bool = False,
|
||||
check_location_can_fill: bool = False) -> None:
|
||||
unplaced_items: list[Item] = []
|
||||
placements: list[Location] = []
|
||||
swapped_items: Counter[tuple[int, str]] = Counter()
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
total = min(len(itempool), len(locations))
|
||||
placed = 0
|
||||
|
||||
@@ -278,15 +270,15 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
if check_location_can_fill:
|
||||
state = CollectionState(multiworld)
|
||||
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
|
||||
else:
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item) -> bool:
|
||||
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||
return location_to_fill.item_rule(item_to_fill)
|
||||
|
||||
while locations and itempool:
|
||||
item_to_place = itempool.pop()
|
||||
spot_to_fill: Location | None = None
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
# going through locations in the same order as the provided `locations` argument
|
||||
for i, location in enumerate(locations):
|
||||
@@ -344,7 +336,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
if unplaced_items and locations:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if move_unplaceable_to_start_inventory:
|
||||
last_batch: list[Item] = []
|
||||
last_batch = []
|
||||
for item in unplaced_items:
|
||||
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
|
||||
multiworld.push_precollected(item)
|
||||
@@ -363,8 +355,8 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
|
||||
|
||||
def fast_fill(multiworld: MultiWorld,
|
||||
item_pool: list[Item],
|
||||
fill_locations: list[Location]) -> tuple[list[Item], list[Location]]:
|
||||
item_pool: typing.List[Item],
|
||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||
placing = min(len(item_pool), len(fill_locations))
|
||||
for item, location in zip(item_pool, fill_locations):
|
||||
multiworld.push_item(location, item, False)
|
||||
@@ -378,14 +370,11 @@ def accessibility_corrections(multiworld: MultiWorld,
|
||||
if pool is None:
|
||||
pool = []
|
||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||
minimal_players = {player
|
||||
for player in multiworld.player_ids
|
||||
if multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
unreachable_locations = [
|
||||
location
|
||||
for location in multiworld.get_locations()
|
||||
if location.player in minimal_players and not location.can_reach(maximum_exploration_state)
|
||||
]
|
||||
minimal_players = {player for player in multiworld.player_ids if
|
||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
unreachable_locations = [location for location in multiworld.get_locations() if
|
||||
location.player in minimal_players and
|
||||
not location.can_reach(maximum_exploration_state)]
|
||||
for location in unreachable_locations:
|
||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||
location.locked and location.item.player not in minimal_players):
|
||||
@@ -400,36 +389,33 @@ def accessibility_corrections(multiworld: MultiWorld,
|
||||
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
|
||||
|
||||
|
||||
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations: Iterable[Location]) -> None:
|
||||
def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
|
||||
maximum_exploration_state = sweep_from_pool(state)
|
||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||
if unreachable_locations:
|
||||
def forbid_important_item_rule(item: Item) -> bool:
|
||||
return not ((item.classification & 0b0011) and
|
||||
multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||
def forbid_important_item_rule(item: Item):
|
||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||
|
||||
for location in unreachable_locations:
|
||||
add_item_rule(location, forbid_important_item_rule)
|
||||
|
||||
|
||||
def distribute_early_items(multiworld: MultiWorld,
|
||||
fill_locations: list[Location],
|
||||
itempool: list[Item]) -> tuple[list[Location], list[Item]]:
|
||||
fill_locations: typing.List[Location],
|
||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||
""" returns new fill_locations and itempool """
|
||||
early_items_count: dict[tuple[str, int], list[int]] = {}
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
||||
for player in multiworld.player_ids:
|
||||
items = itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
|
||||
for item in items:
|
||||
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
|
||||
multiworld.local_early_items[player].get(item, 0)]
|
||||
if early_items_count:
|
||||
early_locations: list[Location] = []
|
||||
early_priority_locations: list[Location] = []
|
||||
loc_indexes_to_remove: set[int] = set()
|
||||
early_locations: typing.List[Location] = []
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
loc_indexes_to_remove: typing.Set[int] = set()
|
||||
base_state = multiworld.state.copy()
|
||||
base_state.sweep_for_advancements(locations=(loc
|
||||
for loc in multiworld.get_filled_locations()
|
||||
if loc.address is None))
|
||||
base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
|
||||
for i, loc in enumerate(fill_locations):
|
||||
if loc.can_reach(base_state):
|
||||
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||
@@ -439,11 +425,11 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
loc_indexes_to_remove.add(i)
|
||||
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
||||
|
||||
early_prog_items: list[Item] = []
|
||||
early_rest_items: list[Item] = []
|
||||
early_local_prog_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
|
||||
early_local_rest_items: dict[int, list[Item]] = {player: [] for player in multiworld.player_ids}
|
||||
item_indexes_to_remove: set[int] = set()
|
||||
early_prog_items: typing.List[Item] = []
|
||||
early_rest_items: typing.List[Item] = []
|
||||
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
|
||||
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
|
||||
item_indexes_to_remove: typing.Set[int] = set()
|
||||
for i, item in enumerate(itempool):
|
||||
if (item.name, item.player) in early_items_count:
|
||||
if item.advancement:
|
||||
@@ -501,7 +487,7 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
|
||||
|
||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
panic_method: Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||
assert all(item.location is None for item in multiworld.itempool), (
|
||||
"At the start of distribute_items_restrictive, "
|
||||
"there are items in the multiworld itempool that are already placed on locations:\n"
|
||||
@@ -516,9 +502,9 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
|
||||
fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
|
||||
|
||||
progitempool: list[Item] = []
|
||||
usefulitempool: list[Item] = []
|
||||
filleritempool: list[Item] = []
|
||||
progitempool: typing.List[Item] = []
|
||||
usefulitempool: typing.List[Item] = []
|
||||
filleritempool: typing.List[Item] = []
|
||||
|
||||
for item in itempool:
|
||||
if item.advancement:
|
||||
@@ -530,7 +516,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
|
||||
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||
|
||||
locations: dict[LocationProgressType, list[Location]] = {
|
||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||
loc_type: [] for loc_type in LocationProgressType}
|
||||
|
||||
for loc in fill_locations:
|
||||
@@ -541,17 +527,17 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
||||
lock_later: list[Location] = []
|
||||
lock_later = []
|
||||
|
||||
def mark_for_locking(location: Location) -> None:
|
||||
def mark_for_locking(location: Location):
|
||||
nonlocal lock_later
|
||||
lock_later.append(location)
|
||||
|
||||
single_player = multiworld.players == 1 and not multiworld.groups
|
||||
|
||||
if prioritylocations:
|
||||
regular_progression: list[Item] = []
|
||||
deprioritized_progression: list[Item] = []
|
||||
regular_progression = []
|
||||
deprioritized_progression = []
|
||||
for item in progitempool:
|
||||
if item.deprioritized:
|
||||
deprioritized_progression.append(item)
|
||||
@@ -637,7 +623,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
|
||||
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
@@ -649,7 +635,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
restitempool = filleritempool + usefulitempool
|
||||
|
||||
remaining_fill(multiworld, defaultlocations, restitempool,
|
||||
move_unplaceable_to_start_inventory=(panic_method == "start_inventory"))
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
|
||||
unplaced = restitempool
|
||||
unfilled = defaultlocations
|
||||
@@ -668,19 +654,18 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
for player in multiworld.player_ids:
|
||||
if more_locations[player]:
|
||||
logging.error(
|
||||
f"Player {multiworld.get_player_name(player)} had "
|
||||
f"{more_locations[player]} more locations than items.")
|
||||
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
||||
elif more_items[player]:
|
||||
logging.warning(
|
||||
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
||||
if unfilled:
|
||||
raise FillError(
|
||||
"Unable to fill all locations.\n"
|
||||
f"Unable to fill all locations.\n" +
|
||||
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Unable to place all items.\n"
|
||||
f"Unable to place all items.\n" +
|
||||
f"Unplaced items({len(unplaced)}): {unplaced}"
|
||||
)
|
||||
|
||||
@@ -730,7 +715,7 @@ def flood_items(multiworld: MultiWorld) -> None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
else:
|
||||
raise FillError("No more progress items left to place.", multiworld=multiworld)
|
||||
raise FillError('No more progress items left to place.', multiworld=multiworld)
|
||||
|
||||
# find item to replace with progress item
|
||||
location_list = multiworld.get_reachable_locations()
|
||||
@@ -753,7 +738,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
# Define a threshold value based on the player with the most available locations.
|
||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||
# which gives more locations available by this sphere.
|
||||
balanceable_players: dict[int, float] = {
|
||||
balanceable_players: typing.Dict[int, float] = {
|
||||
player: multiworld.worlds[player].options.progression_balancing / 100
|
||||
for player in multiworld.player_ids
|
||||
if multiworld.worlds[player].options.progression_balancing > 0
|
||||
@@ -764,15 +749,15 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
||||
logging.debug(balanceable_players)
|
||||
state: CollectionState = CollectionState(multiworld)
|
||||
checked_locations: set[Location] = set()
|
||||
unchecked_locations: set[Location] = set(multiworld.get_locations())
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
|
||||
|
||||
total_locations_count: Counter[int] = Counter(
|
||||
total_locations_count: typing.Counter[int] = Counter(
|
||||
location.player
|
||||
for location in multiworld.get_locations()
|
||||
if not location.locked
|
||||
)
|
||||
reachable_locations_count: dict[int, int] = {
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in multiworld.player_ids
|
||||
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
|
||||
@@ -786,21 +771,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
moved_item_count: int = 0
|
||||
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: set[Location]) -> set[Location]:
|
||||
sphere_locations: set[Location] = set()
|
||||
while True:
|
||||
newly_reachable = {
|
||||
location for location in locations - sphere_locations
|
||||
if sphere_state.can_reach(location)
|
||||
}
|
||||
if not newly_reachable:
|
||||
return sphere_locations
|
||||
|
||||
sphere_locations |= newly_reachable
|
||||
for location in newly_reachable:
|
||||
if (not location.progression_balancing_sphere and location.advancement
|
||||
and location not in sphere_state.locations_checked):
|
||||
sphere_state.collect(location.item, True, location)
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
return num / total_locations_count[player]
|
||||
@@ -847,13 +819,12 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
balancing_reachables = reachable_locations_count.copy()
|
||||
balancing_sphere = sphere_locations.copy()
|
||||
candidate_items: dict[int, set[Location]] = defaultdict(set)
|
||||
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
while True:
|
||||
# Check locations in the current sphere and gather progression items to swap earlier
|
||||
for location in balancing_sphere:
|
||||
if location.advancement:
|
||||
if location not in balancing_state.locations_checked:
|
||||
balancing_state.collect(location.item, True, location)
|
||||
balancing_state.collect(location.item, True, location)
|
||||
player = location.item.player
|
||||
# only replace items that end up in another player's world
|
||||
if (not location.locked and not location.item.skip_in_prog_balancing and
|
||||
@@ -875,11 +846,11 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
||||
# Gather a set of locations which we can swap items into
|
||||
unlocked_locations: dict[int, set[Location]] = defaultdict(set)
|
||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
if l not in balancing_unchecked_locations:
|
||||
unlocked_locations[l.player].add(l)
|
||||
items_to_replace: list[Location] = []
|
||||
items_to_replace: typing.List[Location] = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
items_to_test = list(candidate_items[player])
|
||||
@@ -909,11 +880,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
|
||||
# sort then shuffle to maintain deterministic behaviour,
|
||||
# while allowing use of set for better algorithm growth behaviour elsewhere
|
||||
replacement_locations = sorted(
|
||||
location for location in checked_locations
|
||||
if (not location.advancement and not location.locked
|
||||
and not location.item.skip_in_prog_balancing)
|
||||
)
|
||||
replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked)
|
||||
multiworld.random.shuffle(replacement_locations)
|
||||
items_to_replace.sort()
|
||||
multiworld.random.shuffle(items_to_replace)
|
||||
@@ -944,7 +911,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
||||
sphere_locations.add(location)
|
||||
|
||||
for location in sphere_locations:
|
||||
if location.advancement and location not in state.locations_checked:
|
||||
if location.advancement:
|
||||
state.collect(location.item, True, location)
|
||||
checked_locations |= sphere_locations
|
||||
|
||||
@@ -989,12 +956,6 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
||||
for block in multiworld.worlds[player].options.plando_items:
|
||||
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
||||
target_world = block.world
|
||||
# TODO: This doesn't handle the plando API correctly
|
||||
# It says `world` can be other containers other than list,
|
||||
# but this is checking specifically for list.
|
||||
# (But it's not simple to just change the check to Iterable,
|
||||
# because that will catch the str case too early.)
|
||||
# (And it will be broken without failing unit tests. So also TODO: unit test this.)
|
||||
if target_world is False or multiworld.players == 1: # target own world
|
||||
worlds: set[int] = {player}
|
||||
elif target_world is True: # target any worlds besides own
|
||||
@@ -1024,7 +985,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
||||
worlds = {world_name_lookup[target_world]}
|
||||
new_block.worlds = worlds
|
||||
|
||||
items = block.items
|
||||
items: list[str] | dict[str, typing.Any] = block.items
|
||||
if isinstance(items, dict):
|
||||
item_list: list[str] = []
|
||||
for key, value in items.items():
|
||||
@@ -1085,8 +1046,8 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||
swept_state = multiworld.state.copy()
|
||||
swept_state.sweep_for_advancements()
|
||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||
early_locations: dict[int, list[Location]] = defaultdict(list)
|
||||
non_early_locations: dict[int, list[Location]] = defaultdict(list)
|
||||
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||
for loc in multiworld.get_unfilled_locations():
|
||||
if loc in reachable:
|
||||
early_locations[loc.player].append(loc)
|
||||
@@ -1094,7 +1055,7 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||
non_early_locations[loc.player].append(loc)
|
||||
|
||||
for player in multiworld.plando_item_blocks:
|
||||
removed: list[PlandoItemBlock] = []
|
||||
removed = []
|
||||
for block in multiworld.plando_item_blocks[player]:
|
||||
locations = block.locations
|
||||
resolved_locations = block.resolved_locations
|
||||
@@ -1118,7 +1079,8 @@ def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||
block.count["max"] = len(block.resolved_locations)
|
||||
if block.count["min"] > len(block.resolved_locations):
|
||||
block.count["min"] = len(block.resolved_locations)
|
||||
block.count["target"] = multiworld.random.randint(block.count["min"], block.count["max"])
|
||||
block.count["target"] = multiworld.random.randint(block.count["min"],
|
||||
block.count["max"])
|
||||
|
||||
if not block.count["target"]:
|
||||
removed.append(block)
|
||||
@@ -1156,7 +1118,7 @@ def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[Plando
|
||||
maxcount = placement.count["target"]
|
||||
from_pool = placement.from_pool
|
||||
|
||||
item_candidates: list[Item] = []
|
||||
item_candidates = []
|
||||
if from_pool:
|
||||
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
||||
for item in multiworld.random.sample(items, maxcount):
|
||||
@@ -1182,9 +1144,8 @@ def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[Plando
|
||||
continue
|
||||
else:
|
||||
is_real = item_candidates[0].code is not None
|
||||
candidates = [candidate
|
||||
for candidate in locations
|
||||
if candidate.item is None and bool(candidate.address) == is_real]
|
||||
candidates = [candidate for candidate in locations if candidate.item is None
|
||||
and bool(candidate.address) == is_real]
|
||||
multiworld.random.shuffle(candidates)
|
||||
allstate = multiworld.get_all_state(False)
|
||||
mincount = placement.count["min"]
|
||||
|
||||
+3
-14
@@ -40,8 +40,6 @@ def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||
parser.add_argument('--outputpath', default=settings.general_options.output_path,
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--allow_quantity', action="store_true", default=defaults.allow_quantity,
|
||||
help='Allows the use of the quantity option in yamls. Default is the set value in the host.yaml.')
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||
@@ -125,7 +123,6 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
player_id: int = 1
|
||||
player_files: dict[int, str] = {}
|
||||
player_errors: list[str] = []
|
||||
allow_quantity = args.allow_quantity
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||
@@ -137,14 +134,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
if yaml is None:
|
||||
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
|
||||
else:
|
||||
quantity = yaml.get("quantity", 1)
|
||||
if quantity <= 0:
|
||||
raise ValueError("A quantity of 0 or less is invalid. Please change it to at least 1.")
|
||||
if not allow_quantity and quantity > 1:
|
||||
raise ValueError("Quantity greater than 1 is deactivated by host settings.")
|
||||
|
||||
for _ in range(quantity):
|
||||
weights_for_file.append(yaml)
|
||||
weights_for_file.append(yaml)
|
||||
weights_cache[fname] = tuple(weights_for_file)
|
||||
|
||||
except Exception as e:
|
||||
@@ -279,7 +269,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
f"(name: {args.name.get(player, name)})")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {path} document #{doc_index + 1} (with name: {args.name.get(player, name)}) is invalid. "
|
||||
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
|
||||
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
|
||||
|
||||
# increment for each yaml document in the file
|
||||
@@ -585,8 +575,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
raise Exception(f"Invalid game: {ret.game}")
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
from worlds import failed_world_loads
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + list(failed_world_loads.keys()),
|
||||
limit=1)[0]
|
||||
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
|
||||
if picks[0] in failed_world_loads:
|
||||
raise Exception(f"No functional world found to handle game {ret.game}. "
|
||||
f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||
|
||||
+165
-115
@@ -16,31 +16,125 @@ import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Sequence
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from worlds.LauncherComponents import Component, Type
|
||||
from typing import Any
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import settings
|
||||
import Utils
|
||||
from Utils import env_cleared_lib_path, init_logging, is_linux, is_macos, is_windows, local_path
|
||||
from Utils import (env_cleared_lib_path, init_logging, is_frozen, is_linux, is_macos, is_windows, local_path,
|
||||
messagebox, open_filename, user_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging('Launcher')
|
||||
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
s = settings.get_settings()
|
||||
file = s.filename
|
||||
s.save()
|
||||
assert file, "host.yaml missing"
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
else:
|
||||
webbrowser.open(file)
|
||||
return
|
||||
|
||||
env = env_cleared_lib_path()
|
||||
subprocess.Popen([exe, file], env=env)
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) and \
|
||||
(c.script_name is None or isfile(get_exe(c)[-1])):
|
||||
suffixes += c.file_identifier.suffixes
|
||||
try:
|
||||
filename = open_filename("Select patch", (("Patches", suffixes),))
|
||||
except Exception as e:
|
||||
messagebox("Error", str(e), error=True)
|
||||
else:
|
||||
file, component = identify(filename)
|
||||
if file and component:
|
||||
exe = get_exe(component)
|
||||
if exe is None or not isfile(exe[-1]):
|
||||
exe = get_exe("Launcher")
|
||||
|
||||
launch([*exe, file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls(*args):
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
|
||||
parser.add_argument("--skip_open_folder", action="store_true")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
if not args.skip_open_folder:
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
open_folder(user_path())
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
else:
|
||||
webbrowser.open(folder_path)
|
||||
return
|
||||
|
||||
if exe:
|
||||
env = env_cleared_lib_path()
|
||||
subprocess.Popen([exe, folder_path], env=env)
|
||||
else:
|
||||
logging.warning(f"No file browser available to open {folder_path}")
|
||||
|
||||
|
||||
def update_settings():
|
||||
from settings import get_settings
|
||||
get_settings().save()
|
||||
|
||||
|
||||
def handle_uri(path: str) -> tuple[list["Component"], "Component"]:
|
||||
from worlds.LauncherComponents import components
|
||||
components.extend([
|
||||
# Functions
|
||||
Component("Open host.yaml", func=open_host_yaml,
|
||||
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||
Component("Open Patch", func=open_patch,
|
||||
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||
Component("Generate Template Options", func=generate_yamls,
|
||||
description="Generate template YAMLs for currently installed games."),
|
||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||
description="Open archipelago.gg in your browser."),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||
Component("Unrated/18+ Discord Server", icon="discord",
|
||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||
Component("Browse Files", func=browse_files,
|
||||
description="Open the Archipelago installation folder in your file browser."),
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
client_components = []
|
||||
@@ -54,7 +148,7 @@ def handle_uri(path: str) -> tuple[list["Component"], "Component"]:
|
||||
return client_components, text_client_component
|
||||
|
||||
|
||||
def build_uri_popup(component_list: list["Component"], launch_args: tuple[str, ...]) -> None:
|
||||
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||
from kvui import ButtonsPrompt
|
||||
component_options = {
|
||||
component.display_name: component for component in component_list
|
||||
@@ -66,6 +160,41 @@ def build_uri_popup(component_list: list["Component"], launch_args: tuple[str, .
|
||||
popup.open()
|
||||
|
||||
|
||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component
|
||||
elif path == component.display_name or path == component.script_name:
|
||||
return None, component
|
||||
return None, None
|
||||
|
||||
|
||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith("Archipelago"):
|
||||
name = name[11:]
|
||||
if name.endswith(".exe"):
|
||||
name = name[:-4]
|
||||
if name.endswith(".py"):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f"Archipelago{name}":
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = ".exe" if is_windows else ""
|
||||
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
|
||||
else:
|
||||
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
|
||||
|
||||
|
||||
def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
|
||||
"""Runs the given command/args in `exe` in a new process.
|
||||
@@ -95,7 +224,7 @@ def launch(exe: Sequence[str], in_terminal: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_shortcut(button: Any, component: "Component") -> None:
|
||||
def create_shortcut(button: Any, component: Component) -> None:
|
||||
from pyshortcuts import make_shortcut
|
||||
env = os.environ
|
||||
if "APPIMAGE" in env:
|
||||
@@ -114,14 +243,11 @@ def create_shortcut(button: Any, component: "Component") -> None:
|
||||
refresh_components: Callable[[], None] | None = None
|
||||
|
||||
|
||||
def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
import threading
|
||||
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox,
|
||||
MDScreenManager, MDScreen, LoadingScreen, LogtoLoadingScreen)
|
||||
def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||
from kivy.properties import ObjectProperty
|
||||
from kivy.core.window import Window
|
||||
from kivy.metrics import dp
|
||||
from kivy.clock import Clock
|
||||
from kivymd.uix.button import MDIconButton, MDButton
|
||||
from kivymd.uix.card import MDCard
|
||||
from kivymd.uix.menu import MDDropdownMenu
|
||||
@@ -131,11 +257,11 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
from kivy.lang.builder import Builder
|
||||
|
||||
class LauncherCard(MDCard):
|
||||
component: "Component | None"
|
||||
component: Component | None
|
||||
image: str
|
||||
context_button: MDIconButton = ObjectProperty(None)
|
||||
|
||||
def __init__(self, *args, component: "Component | None" = None, image_path: str = "", **kwargs):
|
||||
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||
self.component = component
|
||||
self.image = image_path
|
||||
super().__init__(args, kwargs)
|
||||
@@ -148,8 +274,7 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
button_layout: ScrollBox = ObjectProperty(None)
|
||||
search_box: MDTextField = ObjectProperty(None)
|
||||
cards: list[LauncherCard]
|
||||
current_filter: Sequence[str, "Type"] | None
|
||||
failed_worlds: bool = False
|
||||
current_filter: Sequence[str | Type] | None
|
||||
|
||||
def __init__(self, ctx=None, components=None, args=None):
|
||||
self.title = self.base_title + " " + Utils.__version__
|
||||
@@ -159,11 +284,6 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
self.launch_components = components
|
||||
self.launch_args = args
|
||||
self.cards = []
|
||||
self.current_filter = ()
|
||||
super().__init__()
|
||||
|
||||
def load_filter(self):
|
||||
from worlds.LauncherComponents import Type
|
||||
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||
persistent = Utils.persistent_load()
|
||||
if "launcher" in persistent:
|
||||
@@ -178,6 +298,7 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
else:
|
||||
filters.append(Type[filter])
|
||||
self.current_filter = filters
|
||||
super().__init__()
|
||||
|
||||
def set_favorite(self, caller):
|
||||
if caller.component.display_name in self.favorites:
|
||||
@@ -187,7 +308,7 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
self.favorites.append(caller.component.display_name)
|
||||
caller.icon = "star"
|
||||
|
||||
def build_card(self, component: "Component") -> LauncherCard:
|
||||
def build_card(self, component: Component) -> LauncherCard:
|
||||
"""
|
||||
Builds a card widget for a given component.
|
||||
|
||||
@@ -195,7 +316,6 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
|
||||
:return: The created Card Widget.
|
||||
"""
|
||||
from worlds.LauncherComponents import icon_paths
|
||||
button_card = LauncherCard(component=component,
|
||||
image_path=icon_paths[component.icon])
|
||||
|
||||
@@ -214,9 +334,8 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
|
||||
return button_card
|
||||
|
||||
def _refresh_components(self, type_filter: Sequence["Type"] | None = None) -> None:
|
||||
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||
if not type_filter:
|
||||
from worlds.LauncherComponents import Type
|
||||
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||
favorites = "favorites" in type_filter
|
||||
|
||||
@@ -247,7 +366,6 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
if len(name) == 0:
|
||||
self._refresh_components(self.current_filter)
|
||||
return
|
||||
from worlds.LauncherComponents import Type
|
||||
|
||||
sub_matches = [
|
||||
card for card in self.cards
|
||||
@@ -258,72 +376,38 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
self.button_layout.layout.add_widget(card)
|
||||
|
||||
def build(self):
|
||||
self.set_colors()
|
||||
self.screen_manager = MDScreenManager()
|
||||
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||
self.loading_screen = LoadingScreen(name="loading")
|
||||
self.screen_manager.add_widget(self.loading_screen)
|
||||
self.grid = self.top_screen.ids.grid
|
||||
self.navigation = self.top_screen.ids.navigation
|
||||
self.button_layout = self.top_screen.ids.button_layout
|
||||
self.search_box = self.top_screen.ids.search_box
|
||||
self.set_colors()
|
||||
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||
|
||||
global refresh_components
|
||||
refresh_components = self._refresh_components
|
||||
|
||||
Window.bind(on_drop_file=self._on_drop_file)
|
||||
Window.bind(on_keyboard=self._on_keyboard)
|
||||
|
||||
for component in components:
|
||||
self.cards.append(self.build_card(component))
|
||||
|
||||
self._refresh_components(self.current_filter)
|
||||
|
||||
# Uncomment to re-enable the Kivy console/live editor
|
||||
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||
# from kivy.modules.console import create_console
|
||||
# create_console(Window, self.top_screen)
|
||||
|
||||
main_screen = MDScreen(name="main")
|
||||
main_screen.add_widget(self.top_screen)
|
||||
self.screen_manager.add_widget(main_screen)
|
||||
|
||||
return self.screen_manager
|
||||
return self.top_screen
|
||||
|
||||
def on_start(self):
|
||||
super().on_start()
|
||||
logger = logging.getLogger("Worlds")
|
||||
logger.propagate = False
|
||||
self.loading_handler = LogtoLoadingScreen(self.loading_screen.update_text)
|
||||
logger.addHandler(self.loading_handler)
|
||||
threading.Thread(target=self.do_loading, name="WorldLoading").start()
|
||||
|
||||
if self.launch_components:
|
||||
build_uri_popup(self.launch_components, self.launch_args)
|
||||
self.launch_components = None
|
||||
self.launch_args = None
|
||||
|
||||
def do_loading(self):
|
||||
import importlib
|
||||
import time
|
||||
start = time.perf_counter()
|
||||
assert "worlds" not in sys.modules, "worlds module already loaded."
|
||||
importlib.import_module("worlds")
|
||||
logging.error(f"Worlds module loaded in {time.perf_counter() - start:.2f} seconds")
|
||||
|
||||
global refresh_components
|
||||
logger = logging.getLogger("Worlds")
|
||||
logger.info("User Data")
|
||||
self.load_filter()
|
||||
|
||||
refresh_components = self._refresh_components
|
||||
logger.info("Finalizing startup")
|
||||
Clock.schedule_once(self.finish_loading)
|
||||
|
||||
def finish_loading(self, dt):
|
||||
from worlds.LauncherComponents import components
|
||||
from worlds import failed_world_loads
|
||||
logger = logging.getLogger("Worlds")
|
||||
self.failed_worlds = bool(failed_world_loads)
|
||||
for component in components:
|
||||
self.cards.append(self.build_card(component))
|
||||
self._refresh_components(self.current_filter)
|
||||
logger.removeHandler(self.loading_handler)
|
||||
self.screen_manager.current = "main"
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
open_text = "Opening in a new window..."
|
||||
@@ -332,50 +416,14 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
button.component.func()
|
||||
else:
|
||||
# if launch returns False, it started the process in background (not in a new terminal)
|
||||
from worlds.LauncherComponents import get_exe
|
||||
if not launch(get_exe(button.component), button.component.cli) and button.component.cli:
|
||||
open_text = "Running in the background..."
|
||||
|
||||
MDSnackbar(MDSnackbarText(text=open_text), y=dp(24), pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
|
||||
@staticmethod
|
||||
def copy_to_clipboard(text):
|
||||
from kivy.core.clipboard import Clipboard
|
||||
Clipboard.copy(text)
|
||||
MDSnackbar(MDSnackbarText(text="Copied to clipboard."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||
size_hint_x=0.5).open()
|
||||
|
||||
def display_failed(self):
|
||||
"""Display a dialog showing the exceptions produced by any world that failed to load during
|
||||
initialization."""
|
||||
if not self.failed_worlds:
|
||||
return
|
||||
from kivymd.uix.dialog import MDDialog, MDDialogIcon, MDDialogHeadlineText, MDDialogContentContainer
|
||||
from kivymd.uix.divider import MDDivider
|
||||
from worlds import failed_world_loads
|
||||
from kivymd.uix.list import MDListItem, MDListItemHeadlineText, MDListItemSupportingText
|
||||
entries = []
|
||||
for world, reason in failed_world_loads.items():
|
||||
entries.append(MDListItem(
|
||||
MDListItemHeadlineText(text=world),
|
||||
MDListItemSupportingText(text=reason),
|
||||
on_release=lambda x, r=reason: self.copy_to_clipboard(r)
|
||||
))
|
||||
dialog = MDDialog(
|
||||
MDDialogIcon(icon="alert"),
|
||||
MDDialogHeadlineText(text="Failed World Loads"),
|
||||
MDDialogContentContainer(
|
||||
MDDivider(),
|
||||
*entries,
|
||||
orientation="vertical",
|
||||
)
|
||||
)
|
||||
dialog.open()
|
||||
|
||||
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
|
||||
""" When a patch file is dropped into the window, run the associated component. """
|
||||
from worlds.LauncherComponents import identify
|
||||
file, component = identify(filename.decode())
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
@@ -398,7 +446,6 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
super()._stop(*largs)
|
||||
|
||||
def on_stop(self):
|
||||
from worlds.LauncherComponents import Type
|
||||
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||
for filter in self.current_filter))
|
||||
@@ -412,11 +459,15 @@ def run_gui(launch_components: list["Component"], args: Any) -> None:
|
||||
refresh_components = None
|
||||
|
||||
|
||||
def run_component(component: "Component", *args):
|
||||
global refresh_components
|
||||
component.run(*args)
|
||||
if refresh_components:
|
||||
refresh_components()
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
if refresh_components:
|
||||
refresh_components()
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
logging.warning(f"Component {component} does not appear to be executable.")
|
||||
|
||||
|
||||
def main(args: argparse.Namespace | dict | None = None):
|
||||
@@ -436,7 +487,6 @@ def main(args: argparse.Namespace | dict | None = None):
|
||||
else:
|
||||
args['launch_components'] = [text_client_component, *components]
|
||||
else:
|
||||
from worlds.LauncherComponents import identify
|
||||
file, component = identify(path)
|
||||
if file:
|
||||
args['file'] = file
|
||||
|
||||
+2
-2
@@ -241,8 +241,8 @@ async def gba_sync_task(ctx: MMBN3Context):
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
|
||||
"Please update to the latest version. "
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
|
||||
"Please update to the latest version."
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -207,8 +207,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
else:
|
||||
logger.info("Progression balancing skipped.")
|
||||
|
||||
logger.info("Running pre-output steps.")
|
||||
|
||||
AutoWorld.call_all(multiworld, "finalize_multiworld")
|
||||
AutoWorld.call_all(multiworld, "pre_output")
|
||||
|
||||
|
||||
+3
-4
@@ -38,8 +38,7 @@ class RequirementsSet(set):
|
||||
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
core_constraints = os.path.join(local_dir, 'requirements.txt')
|
||||
requirements_files = RequirementsSet((core_constraints,))
|
||||
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
@@ -70,7 +69,7 @@ def confirm(msg: str):
|
||||
def update_command():
|
||||
check_pip()
|
||||
for file in requirements_files:
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--constraint", core_constraints])
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
||||
|
||||
|
||||
def install_pkg_resources(yes=False):
|
||||
@@ -80,7 +79,7 @@ def install_pkg_resources(yes=False):
|
||||
check_pip()
|
||||
if not yes:
|
||||
confirm("pkg_resources not found, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "setuptools>=75,<81"])
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
||||
|
||||
|
||||
def update(yes: bool = False, force: bool = False) -> None:
|
||||
|
||||
+11
-102
@@ -21,7 +21,6 @@ import time
|
||||
import typing
|
||||
import weakref
|
||||
import zlib
|
||||
from functools import reduce
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
|
||||
import ModuleUpdate
|
||||
@@ -61,40 +60,6 @@ server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
|
||||
compress_settings={"memLevel": 4},
|
||||
)
|
||||
|
||||
OPERATOR_NAME_TO_OPERATOR = {
|
||||
"or": operator.or_,
|
||||
"and": operator.and_,
|
||||
}
|
||||
|
||||
class BounceTarget(typing.NamedTuple):
|
||||
teams: set[int]
|
||||
games: set[str]
|
||||
tags: set[str]
|
||||
slots: set[int]
|
||||
|
||||
def _teams_match(self, target: Client) -> bool:
|
||||
return target.team in self.teams
|
||||
|
||||
def _games_match(self, target: Client) -> bool:
|
||||
return len(self.games) == 0 or target.ctx.games[target.slot] in self.games
|
||||
|
||||
def _tags_match(self, target: Client) -> bool:
|
||||
return len(self.tags) == 0 or bool(set(target.tags) & self.tags)
|
||||
|
||||
def _slots_match(self, target: Client) -> bool:
|
||||
return len(self.slots) == 0 or target.slot in self.slots
|
||||
|
||||
def matches_client_legacy(self, target: Client) -> bool:
|
||||
return self._teams_match(target) and (
|
||||
self._games_match(target) or self._tags_match(target) or self._slots_match(target)
|
||||
)
|
||||
|
||||
def matches_client_operator(self, target: Client, op: typing.Callable[[typing.Any, typing.Any], bool]):
|
||||
return reduce(
|
||||
op,
|
||||
(self._teams_match(target), self._games_match(target), self._tags_match(target), self._games_match(target)),
|
||||
)
|
||||
|
||||
|
||||
def remove_from_list(container, value):
|
||||
try:
|
||||
@@ -2182,66 +2147,17 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
client.messageprocessor(args["text"])
|
||||
|
||||
elif cmd == "Bounce":
|
||||
games = args.get("games", [])
|
||||
if not isinstance(games, (list, set)) or not all(isinstance(entry, str) for entry in games):
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "InvalidPacket", "type": "arguments",
|
||||
"text": "Bounce: Games list provided did not have the correct format.",
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
games = set(games)
|
||||
|
||||
tags = args.get("tags", [])
|
||||
if not isinstance(tags, (list, set)) or not all(isinstance(entry, str) for entry in tags):
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "InvalidPacket", "type": "arguments",
|
||||
"text": "Bounce: Tags list provided did not have the correct format.",
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
tags = set(tags)
|
||||
|
||||
slots = args.get("slots", [])
|
||||
if not isinstance(slots, (list, set)) or not all(isinstance(entry, int) for entry in slots):
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "InvalidPacket", "type": "arguments",
|
||||
"text": "Bounce: Slots list provided did not have the correct format.",
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
slots = set(slots)
|
||||
|
||||
teams = args.get("teams", [client.team])
|
||||
if not isinstance(teams, (list, set)) or not all(isinstance(entry, int) for entry in teams):
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "InvalidPacket", "type": "arguments",
|
||||
"text": "Bounce: Teams list provided did not have the correct format.",
|
||||
"original_cmd": cmd}])
|
||||
return
|
||||
|
||||
teams = set(teams)
|
||||
|
||||
bounce_target = BounceTarget(teams, games, tags, slots)
|
||||
|
||||
games = set(args.get("games", []))
|
||||
tags = set(args.get("tags", []))
|
||||
slots = set(args.get("slots", []))
|
||||
args["cmd"] = "Bounced"
|
||||
msg = ctx.dumper([args])
|
||||
|
||||
boolean_operator = args.get("operator", "legacy")
|
||||
|
||||
if boolean_operator == "legacy":
|
||||
for bounce_client in ctx.endpoints:
|
||||
if bounce_target.matches_client_legacy(bounce_client):
|
||||
await ctx.send_encoded_msgs(bounce_client, msg)
|
||||
elif boolean_operator in OPERATOR_NAME_TO_OPERATOR:
|
||||
op = OPERATOR_NAME_TO_OPERATOR[boolean_operator]
|
||||
for bounce_client in ctx.endpoints:
|
||||
if bounce_target.matches_client_operator(bounce_client, op):
|
||||
await ctx.send_encoded_msgs(bounce_client, msg)
|
||||
else:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": "Bounce", "original_cmd": cmd}])
|
||||
return
|
||||
for bounceclient in ctx.endpoints:
|
||||
if client.team == bounceclient.team and (ctx.games[bounceclient.slot] in games or
|
||||
set(bounceclient.tags) & tags or
|
||||
bounceclient.slot in slots):
|
||||
await ctx.send_encoded_msgs(bounceclient, msg)
|
||||
|
||||
elif cmd == "Get":
|
||||
if "keys" not in args or type(args["keys"]) != list:
|
||||
@@ -2625,14 +2541,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
|
||||
elif option_name in {"hint_cost", "location_check_points"}:
|
||||
# Update hint point amounts per slot
|
||||
for team, players in self.ctx.clients.items():
|
||||
for slot, clients in players.items():
|
||||
self.ctx.broadcast(clients, [{
|
||||
"cmd": "RoomUpdate",
|
||||
option_name: getattr(self.ctx, option_name),
|
||||
"hint_points": get_slot_points(self.ctx, team, slot),
|
||||
}])
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
|
||||
return True
|
||||
|
||||
def _cmd_datastore(self):
|
||||
@@ -2724,8 +2633,8 @@ def parse_args() -> argparse.Namespace:
|
||||
goal: !remaining can be used after goal completion
|
||||
''')
|
||||
parser.add_argument('--auto_shutdown', default=defaults["auto_shutdown"], type=int,
|
||||
help="automatically shut down the server after this many seconds without new location checks. "
|
||||
"0 to keep running.")
|
||||
help="automatically shut down the server after this many minutes without new location checks. "
|
||||
"0 to keep running. Not yet implemented.")
|
||||
parser.add_argument('--use_embedded_options', action="store_true",
|
||||
help='retrieve release, remaining and hint options from the multidata file,'
|
||||
' instead of host.yaml')
|
||||
|
||||
+1
-37
@@ -170,39 +170,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
return o
|
||||
|
||||
|
||||
_decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
try:
|
||||
from _speedups import check_json_depth as _check_depth
|
||||
except ImportError:
|
||||
def _check_depth(s: str, limit: int = 16) -> None:
|
||||
depth = 1
|
||||
in_quotes = False
|
||||
escape = False
|
||||
for c in s:
|
||||
if c == "\\" and not escape:
|
||||
escape = True
|
||||
continue
|
||||
if c == '"' and not escape:
|
||||
in_quotes = not in_quotes
|
||||
continue
|
||||
if not in_quotes:
|
||||
if c in "[{":
|
||||
depth += 1
|
||||
if depth > limit:
|
||||
raise ValueError("JSON document too complex")
|
||||
elif c in "}]":
|
||||
depth -= 1
|
||||
escape = False
|
||||
|
||||
if depth != 1: # free check
|
||||
raise ValueError("JSON document malformed")
|
||||
|
||||
|
||||
def decode(s: str) -> typing.Any:
|
||||
_check_depth(s) # raises ValueError
|
||||
return _decode(s)
|
||||
decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
@@ -559,11 +527,7 @@ else:
|
||||
except ImportError:
|
||||
pyximport = None
|
||||
try:
|
||||
import logging
|
||||
logger = logging.getLogger()
|
||||
old_level = logger.level
|
||||
from _speedups import LocationStore
|
||||
logger.setLevel(old_level)
|
||||
except ImportError:
|
||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
||||
|
||||
+32
-59
@@ -212,13 +212,6 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
else:
|
||||
return cls.name_lookup[value]
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if isinstance(other, self.__class__):
|
||||
return self.value == other.value
|
||||
if isinstance(other, Option):
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
return self.value == other
|
||||
|
||||
def __int__(self) -> T:
|
||||
return self.value
|
||||
|
||||
@@ -937,35 +930,13 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
||||
class OptionCounter(OptionDict):
|
||||
min: int | None = None
|
||||
max: int | None = None
|
||||
cull_zeroes: bool = False
|
||||
value: collections.Counter[str]
|
||||
|
||||
def __init__(self, value: dict[str, int]) -> None:
|
||||
cleaned_dict = collections.Counter()
|
||||
|
||||
invalid_value_errors = []
|
||||
for key, subvalue in value.items():
|
||||
if not isinstance(subvalue, (int, float)) or int(subvalue) != subvalue:
|
||||
invalid_value_errors += [f"Invalid value {subvalue} for key {key}, must be an integer."]
|
||||
continue
|
||||
|
||||
if self.cull_zeroes and subvalue == 0:
|
||||
continue
|
||||
|
||||
cleaned_dict[key] = int(subvalue)
|
||||
|
||||
if invalid_value_errors:
|
||||
type_errors = [f"For option {self.__class__.__name__}:"] + invalid_value_errors
|
||||
raise TypeError("\n".join(type_errors))
|
||||
|
||||
self.value = cleaned_dict
|
||||
super(OptionCounter, self).__init__(collections.Counter(value))
|
||||
|
||||
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||
super(OptionCounter, self).verify(world, player_name, plando_options)
|
||||
|
||||
self.verify_values()
|
||||
|
||||
def verify_values(self):
|
||||
range_errors = []
|
||||
|
||||
if self.max is not None:
|
||||
@@ -988,8 +959,13 @@ class OptionCounter(OptionDict):
|
||||
class ItemDict(OptionCounter):
|
||||
verify_item_name = True
|
||||
|
||||
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||
cull_zeroes = True
|
||||
min = 0
|
||||
|
||||
def __init__(self, value: dict[str, int]) -> None:
|
||||
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
@@ -1199,14 +1175,14 @@ class ConnectionsMeta(AssembleOptions):
|
||||
|
||||
|
||||
class PlandoConnection(typing.NamedTuple):
|
||||
class Direction(enum.StrEnum):
|
||||
class Direction:
|
||||
entrance = "entrance"
|
||||
exit = "exit"
|
||||
both = "both"
|
||||
|
||||
entrance: str
|
||||
exit: str
|
||||
direction: Direction
|
||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
||||
percentage: int = 100
|
||||
|
||||
|
||||
@@ -1470,7 +1446,7 @@ class NonLocalItems(ItemSet):
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with the specified amount of these items. Example: {Bomb: 1, Arrow: 3} """
|
||||
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
rich_text_doc = True
|
||||
@@ -1478,7 +1454,7 @@ class StartInventory(ItemDict):
|
||||
|
||||
|
||||
class StartInventoryPool(StartInventory):
|
||||
"""Start with the specified amount of these items and don't place them in the world. Example: {Bomb: 1, Arrow: 3}
|
||||
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
|
||||
|
||||
The game decides what the replacement items will be.
|
||||
"""
|
||||
@@ -1857,30 +1833,27 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
try:
|
||||
presets = world.web.options_presets.copy()
|
||||
presets.update({"": {}})
|
||||
presets = world.web.options_presets.copy()
|
||||
presets.update({"": {}})
|
||||
|
||||
option_groups = get_option_groups(world)
|
||||
for name, preset in presets.items():
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
preset_name=name,
|
||||
preset=preset,
|
||||
)
|
||||
preset_name = f" - {name}" if name else ""
|
||||
with open(os.path.join(preset_folder if name else target_folder,
|
||||
get_file_safe_name(game_name + preset_name) + ".yaml"),
|
||||
"w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
except Exception as ex:
|
||||
raise Exception(f"Template generation failed for world {game_name}") from ex
|
||||
option_groups = get_option_groups(world)
|
||||
for name, preset in presets.items():
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
preset_name=name,
|
||||
preset=preset,
|
||||
)
|
||||
preset_name = f" - {name}" if name else ""
|
||||
with open(os.path.join(preset_folder if name else target_folder,
|
||||
get_file_safe_name(game_name + preset_name) + ".yaml"),
|
||||
"w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
|
||||
@@ -85,7 +85,6 @@ Currently, the following games are supported:
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
* Mega Man 3
|
||||
* Gauntlet Legends
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if tempInstall and not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
if tempInstall is None:
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
|
||||
@@ -18,12 +18,11 @@ import logging
|
||||
import warnings
|
||||
|
||||
from argparse import Namespace
|
||||
from collections.abc import Collection, Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from settings import Settings, get_settings
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Generic, Mapping, Optional, Set, Dict, Any, TypeVar, Union, TypeGuard
|
||||
from typing import BinaryIO, Coroutine, Mapping, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
from yaml import load, load_all, dump
|
||||
from pathspec import PathSpec, GitIgnoreSpec
|
||||
from typing_extensions import deprecated
|
||||
@@ -53,7 +52,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.6.8"
|
||||
__version__ = "0.6.7"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -451,10 +450,13 @@ safe_builtins = frozenset((
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
generic_properties_module: Optional[object]
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||
self.options_module = importlib.import_module("Options")
|
||||
self.net_utils_module = importlib.import_module("NetUtils")
|
||||
self.generic_properties_module = None
|
||||
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
@@ -468,6 +470,10 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name == "PlandoItem":
|
||||
if not self.generic_properties_module:
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
return getattr(self.generic_properties_module, name)
|
||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||
if module.lower().endswith("options"):
|
||||
if module == "Options":
|
||||
@@ -1012,6 +1018,23 @@ def deprecate(message: str, add_stacklevels: int = 0):
|
||||
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||
|
||||
|
||||
class DeprecateDict(dict):
|
||||
log_message: str
|
||||
should_error: bool
|
||||
|
||||
def __init__(self, message: str, error: bool = False) -> None:
|
||||
self.log_message = message
|
||||
self.should_error = error
|
||||
super().__init__()
|
||||
|
||||
def __getitem__(self, item: Any) -> Any:
|
||||
if self.should_error:
|
||||
deprecate(self.log_message, add_stacklevels=1)
|
||||
elif __debug__:
|
||||
warnings.warn(self.log_message, stacklevel=2)
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
def _extend_freeze_support() -> None:
|
||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
||||
# original upstream issue: https://github.com/python/cpython/issues/76327
|
||||
@@ -1071,7 +1094,6 @@ def visualize_regions(
|
||||
file_name: str,
|
||||
*,
|
||||
show_entrance_names: bool = False,
|
||||
show_entrance_rules: bool = False,
|
||||
show_locations: bool = True,
|
||||
show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True,
|
||||
@@ -1084,7 +1106,6 @@ def visualize_regions(
|
||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||
:param file_name: The name of the destination .puml file.
|
||||
:param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
|
||||
:param show_entrance_rules: (default False) If enabled, the Rule Builder explanation of the entrance's access rule will be shown near each connection.
|
||||
:param show_locations: (default True) If enabled, the locations will be listed inside each region.
|
||||
Priority locations will be shown in bold.
|
||||
Excluded locations will be stricken out.
|
||||
@@ -1174,22 +1195,13 @@ def visualize_regions(
|
||||
return re.sub("[\".:]", "", name)
|
||||
|
||||
def visualize_exits(region: Region) -> None:
|
||||
import rule_builder.rules
|
||||
for exit_ in region.exits:
|
||||
color_code: str = ""
|
||||
if exit_.randomization_group in entrance_highlighting:
|
||||
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
|
||||
if exit_.connected_region:
|
||||
label = ""
|
||||
if show_entrance_names:
|
||||
label += fmt(exit_)
|
||||
if show_entrance_rules:
|
||||
if isinstance(exit_.access_rule, rule_builder.rules.Rule.Resolved):
|
||||
if label:
|
||||
label += "\\n"
|
||||
label += exit_.access_rule.explain_str()
|
||||
if label:
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{label}\"{color_code}")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
|
||||
else:
|
||||
try:
|
||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
|
||||
@@ -1265,11 +1277,8 @@ def visualize_regions(
|
||||
f.write("\n".join(uml))
|
||||
|
||||
|
||||
_T_co = TypeVar("_T_co", covariant=True)
|
||||
|
||||
|
||||
class RepeatableChain(Generic[_T_co]):
|
||||
def __init__(self, iterable: Iterable[Collection[_T_co]]):
|
||||
class RepeatableChain:
|
||||
def __init__(self, iterable: typing.Iterable):
|
||||
self.iterable = iterable
|
||||
|
||||
def __iter__(self):
|
||||
@@ -1281,9 +1290,6 @@ class RepeatableChain(Generic[_T_co]):
|
||||
def __len__(self):
|
||||
return sum(len(iterable) for iterable in self.iterable)
|
||||
|
||||
def __contains__(self, o: object) -> bool:
|
||||
return any(o in sub_iterable for sub_iterable in self.iterable)
|
||||
|
||||
|
||||
def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
|
||||
""" `str` is `Iterable`, but that's not what we want """
|
||||
|
||||
@@ -42,15 +42,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["GAME_PORTS"] = ["49152-65535", 0]
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# maximum time in seconds since last activity for a room to be hosted
|
||||
app.config["MAX_ROOM_TIMEOUT"] = 259200
|
||||
# minimum time in days since last activity for a room to be deleted. 0 to disable.
|
||||
app.config["ROOM_AUTO_DELETE"] = 0
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
|
||||
@@ -74,9 +71,7 @@ CLI(app)
|
||||
|
||||
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
if "=" in value or any(c.isspace() for c in value):
|
||||
raise ValueError("Invalid UUID format")
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)))
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||
|
||||
|
||||
def to_url(value: uuid.UUID) -> str:
|
||||
|
||||
@@ -100,18 +100,13 @@ def init_generator(config: dict[str, Any]) -> None:
|
||||
db.generate_mapping()
|
||||
|
||||
|
||||
def cleanup(config: dict[str, Any]):
|
||||
"""delete unowned or old user-content"""
|
||||
auto_delete: int = config.get("ROOM_AUTO_DELETE", 0)
|
||||
def cleanup():
|
||||
"""delete unowned user-content"""
|
||||
with db_session:
|
||||
# >>> bool(uuid.UUID(int=0))
|
||||
# True
|
||||
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
|
||||
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
|
||||
if auto_delete > 0:
|
||||
cutoff = utcnow() - timedelta(days=auto_delete)
|
||||
rooms += Room.select(lambda room: room.last_activity < cutoff).delete(bulk=True)
|
||||
seeds += Seed.select(lambda seed: not seed.rooms and seed.creation_time < cutoff).delete(bulk=True)
|
||||
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
|
||||
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
|
||||
if rooms or seeds or slots:
|
||||
@@ -123,7 +118,7 @@ def autohost(config: dict):
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
cleanup(config)
|
||||
cleanup()
|
||||
hosters = []
|
||||
for x in range(config["HOSTERS"]):
|
||||
hoster = MultiworldInstance(config, x)
|
||||
@@ -193,7 +188,6 @@ class MultiworldInstance():
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
self.game_ports = config["GAME_PORTS"]
|
||||
self.rooms_to_start = multiprocessing.Queue()
|
||||
self.rooms_shutting_down = multiprocessing.Queue()
|
||||
self.name = f"MultiHoster{id}"
|
||||
@@ -204,7 +198,7 @@ class MultiworldInstance():
|
||||
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host, self.game_ports,
|
||||
self.cert, self.key, self.host,
|
||||
self.rooms_to_start, self.rooms_shutting_down),
|
||||
name=self.name)
|
||||
process.start()
|
||||
|
||||
+19
-116
@@ -4,7 +4,6 @@ import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
@@ -14,9 +13,7 @@ import threading
|
||||
import time
|
||||
import typing
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
|
||||
import psutil
|
||||
import websockets
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
@@ -27,7 +24,6 @@ from MultiServer import (
|
||||
server_per_message_deflate_factory,
|
||||
)
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
|
||||
from .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
|
||||
@@ -80,8 +76,12 @@ class WebHostContext(Context):
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def __del__(self):
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
try:
|
||||
import psutil
|
||||
from Utils import format_SI_prefix
|
||||
self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
@@ -115,7 +115,7 @@ class WebHostContext(Context):
|
||||
if room.last_port:
|
||||
self.port = room.last_port
|
||||
else:
|
||||
self.port = 0
|
||||
self.port = get_random_port()
|
||||
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
@@ -181,97 +181,8 @@ class WebHostContext(Context):
|
||||
return d
|
||||
|
||||
|
||||
class GameRangePorts(typing.NamedTuple):
|
||||
valid_ports: list[int]
|
||||
ephemeral_allowed: bool
|
||||
|
||||
|
||||
class RandomPortSocketCreator:
|
||||
""" Creates server sockets on random available ports from a configured range. """
|
||||
|
||||
_next_port_index: int
|
||||
_used_ports_cache: tuple[frozenset[int], int] | None
|
||||
_parsed_ports: GameRangePorts
|
||||
|
||||
def __init__(self, game_ports: Iterable[str | int]) -> None:
|
||||
self._next_port_index = 0
|
||||
self._used_ports_cache = None
|
||||
self._parsed_ports = self._parse_game_ports(game_ports)
|
||||
|
||||
@staticmethod
|
||||
def _parse_game_ports(game_ports: Iterable[str | int]) -> GameRangePorts:
|
||||
""" Parse the game ports configuration into a structured format. """
|
||||
valid_ports: list[int] = []
|
||||
ephemeral_allowed = False
|
||||
|
||||
for item in game_ports:
|
||||
if isinstance(item, str) and "-" in item:
|
||||
start, end = map(int, item.split("-"))
|
||||
x = range(start, end + 1)
|
||||
valid_ports.extend(x)
|
||||
elif int(item) == 0:
|
||||
ephemeral_allowed = True
|
||||
else:
|
||||
valid_ports.append(int(item))
|
||||
|
||||
random.shuffle(valid_ports)
|
||||
return GameRangePorts(valid_ports, ephemeral_allowed)
|
||||
|
||||
@staticmethod
|
||||
def _try_conns_per_process(p: psutil.Process) -> Iterable[int]:
|
||||
""" Get ports from a single process's connections. """
|
||||
try:
|
||||
return (c.laddr.port for c in p.net_connections("tcp4") if c.laddr)
|
||||
except psutil.AccessDenied:
|
||||
return ()
|
||||
|
||||
@staticmethod
|
||||
def _get_active_net_connections() -> Iterable[int]:
|
||||
""" Get all active TCP4 connections on the system. """
|
||||
# Don't even try to check if system using AIX
|
||||
if psutil.AIX:
|
||||
return ()
|
||||
|
||||
try:
|
||||
return (c.laddr.port for c in psutil.net_connections("tcp4") if c.laddr)
|
||||
# raises AccessDenied when done on macOS
|
||||
except psutil.AccessDenied:
|
||||
# flatten the list of iterables
|
||||
return itertools.chain.from_iterable(map(
|
||||
RandomPortSocketCreator._try_conns_per_process,
|
||||
psutil.process_iter(["net_connections"])
|
||||
))
|
||||
|
||||
def _get_used_ports(self) -> frozenset[int]:
|
||||
""" Get currently used ports with 90-second caching. """
|
||||
t_hash = round(time.monotonic() / 90)
|
||||
if self._used_ports_cache is None or self._used_ports_cache[1] != t_hash:
|
||||
self._used_ports_cache = (frozenset(self._get_active_net_connections()), t_hash)
|
||||
|
||||
return self._used_ports_cache[0]
|
||||
|
||||
def create(self, host: str) -> socket.socket:
|
||||
""" Create a server socket on an available port. """
|
||||
valid_ports, ephemeral_allowed = self._parsed_ports
|
||||
used_ports = self._get_used_ports()
|
||||
|
||||
next_index = self._next_port_index
|
||||
for i, port in enumerate(itertools.chain(valid_ports[next_index:], valid_ports[:next_index])):
|
||||
if port in used_ports:
|
||||
continue
|
||||
|
||||
try:
|
||||
res = socket.create_server((host, port))
|
||||
next_index = (next_index + i + 1) % len(valid_ports)
|
||||
self._next_port_index = next_index
|
||||
return res
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if ephemeral_allowed:
|
||||
return socket.create_server((host, 0))
|
||||
|
||||
raise OSError(98, "No available ports")
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
|
||||
@cache_argsless
|
||||
@@ -336,8 +247,7 @@ def tear_down_logging(room_id):
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, game_ports: Iterable[str | int],
|
||||
rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(name)
|
||||
@@ -381,7 +291,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
socket_creator = RandomPortSocketCreator(game_ports)
|
||||
|
||||
async def start_room(room_id):
|
||||
with Locker(f"RoomLocker {room_id}"):
|
||||
@@ -391,26 +300,20 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
if ctx.port != 0:
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError:
|
||||
ctx.port = 0
|
||||
if ctx.port == 0:
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
sock=socket_creator.create(ctx.host),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
@@ -485,7 +388,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
|
||||
@@ -10,6 +10,5 @@ Flask-Cors==6.0.2
|
||||
bokeh==3.8.2
|
||||
markupsafe==3.0.3
|
||||
setproctitle==1.3.7
|
||||
mistune==3.3.0
|
||||
mistune==3.2.0
|
||||
docutils==0.22.4
|
||||
psutil==7.2.2
|
||||
@@ -123,26 +123,12 @@ window.addEventListener('load', () => {
|
||||
});
|
||||
|
||||
const addRangeRow = (optionName) => {
|
||||
const inputQuery = `input[data-option="${optionName}"]`;
|
||||
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
|
||||
const inputTarget = document.querySelector(inputQuery);
|
||||
const newValue = inputTarget.value;
|
||||
switch (inputTarget.type) {
|
||||
case 'number':
|
||||
if (!/^-?\d+$/.test(newValue)) {
|
||||
alert('Range values must be a positive or negative integer!');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'text':
|
||||
if (newValue === "") {
|
||||
alert('Range values for text must be a non-empty string!');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error(`Found unsupported input type: ${inputTarget.type}`);
|
||||
return;
|
||||
break;
|
||||
if (!/^-?\d+$/.test(newValue)) {
|
||||
alert('Range values must be a positive or negative integer!');
|
||||
return;
|
||||
}
|
||||
inputTarget.value = '';
|
||||
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
|
||||
|
||||
@@ -42,7 +42,3 @@
|
||||
#games .page-controls button{
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
#games .author-label{
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -49,13 +49,6 @@
|
||||
<details data-game="{{ game_name }}">
|
||||
<summary class="h2">{{ game_name }}</summary>
|
||||
{{ world.__doc__ | default("No description provided.", true) }}<br />
|
||||
{% if "authors" in world.manifest %}
|
||||
{% if world.manifest["authors"]|length == 1 %}
|
||||
<p class="author-label">Author: {{ world.manifest["authors"][0] }}</p>
|
||||
{% else %}
|
||||
<p class="author-label">Authors: {{ world.manifest["authors"] | join(", ") }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
|
||||
{% if world.web.tutorials %}
|
||||
<span class="link-spacer">|</span>
|
||||
|
||||
@@ -71,10 +71,10 @@
|
||||
<div class="hint-text">
|
||||
This option allows custom values only. Please enter your desired values below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input type="text" class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
<table class="range-rows" data-option="{{ option_name }}">
|
||||
<table>
|
||||
<tbody>
|
||||
{% if option.default %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default) }}
|
||||
@@ -88,11 +88,11 @@
|
||||
<div class="hint-text">
|
||||
Custom values are also allowed for this option. To create one, enter it into the input box below.
|
||||
<div class="custom-value-wrapper">
|
||||
<input type="text" class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
|
||||
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
|
||||
<button type="button" data-option="{{ option_name }}">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="range-rows" data-option="{{ option_name }}">
|
||||
<table>
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
|
||||
@@ -376,27 +376,3 @@ cdef class PlayerLocationProxy:
|
||||
count = self._store.sender_index[self._player].count
|
||||
for entry in self._store.entries[start:start+count]:
|
||||
yield entry.location, (entry.item, entry.receiver, entry.flags)
|
||||
|
||||
|
||||
cpdef void check_json_depth(s: str, limit: int = 16):
|
||||
cdef Py_ssize_t depth = 1
|
||||
cdef bool in_quotes = False
|
||||
cdef bool escape = False
|
||||
for c in s:
|
||||
if c == "\\" and not escape:
|
||||
escape = True
|
||||
continue
|
||||
if c == '"' and not escape:
|
||||
in_quotes = not in_quotes
|
||||
continue
|
||||
if not in_quotes:
|
||||
if c in "[{":
|
||||
depth += 1
|
||||
if depth > limit:
|
||||
raise ValueError("JSON document too complex")
|
||||
elif c in "}]":
|
||||
depth -= 1
|
||||
escape = False
|
||||
|
||||
if depth != 1: # free check
|
||||
raise ValueError("JSON document malformed")
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
# AP_TEST_WORLDS implies `-m world` unless an explicit -m was given; the scoping itself lives in
|
||||
# worlds/__init__
|
||||
if os.environ.get("AP_TEST_WORLDS") and not config.option.markexpr:
|
||||
config.option.markexpr = "world"
|
||||
|
||||
|
||||
def pytest_ignore_collect(collection_path, config):
|
||||
# skip worlds/<world>/... for any world not named, so other worlds are never imported
|
||||
env = os.environ.get("AP_TEST_WORLDS")
|
||||
if not env:
|
||||
return None
|
||||
selected = {name.strip() for name in env.split(",") if name.strip()}
|
||||
parts = collection_path.parts
|
||||
if "worlds" in parts:
|
||||
i = parts.index("worlds")
|
||||
if i + 1 < len(parts) and parts[i + 1] not in selected:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items):
|
||||
# mark for `-m world`: classes with `world_relevant = True`, plus anything under worlds/ (nodeid is
|
||||
# always "/"-separated and relative to rootdir)
|
||||
for item in items:
|
||||
if getattr(getattr(item, "cls", None), "world_relevant", False) or \
|
||||
item.nodeid.split("/", 1)[0] == "worlds":
|
||||
item.add_marker("world")
|
||||
+6
-36
@@ -1,4 +1,3 @@
|
||||
#:import Utils Utils
|
||||
<LauncherCard>:
|
||||
id: main
|
||||
style: "filled"
|
||||
@@ -62,6 +61,7 @@
|
||||
text: "Open"
|
||||
|
||||
|
||||
#:import Type worlds.LauncherComponents.Type
|
||||
MDFloatLayout:
|
||||
id: top_screen
|
||||
|
||||
@@ -79,7 +79,7 @@ MDFloatLayout:
|
||||
MDButton:
|
||||
id: all
|
||||
style: "text"
|
||||
type: ("CLIENT", "TOOL", "ADJUSTER", "MISC")
|
||||
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||
on_release: app.filter_clients_by_type(self)
|
||||
|
||||
MDButtonIcon:
|
||||
@@ -89,7 +89,7 @@ MDFloatLayout:
|
||||
MDButton:
|
||||
id: client
|
||||
style: "text"
|
||||
type: ("CLIENT", )
|
||||
type: (Type.CLIENT, )
|
||||
on_release: app.filter_clients_by_type(self)
|
||||
|
||||
MDButtonIcon:
|
||||
@@ -99,7 +99,7 @@ MDFloatLayout:
|
||||
MDButton:
|
||||
id: Tool
|
||||
style: "text"
|
||||
type: ("TOOL", )
|
||||
type: (Type.TOOL, )
|
||||
on_release: app.filter_clients_by_type(self)
|
||||
|
||||
MDButtonIcon:
|
||||
@@ -109,7 +109,7 @@ MDFloatLayout:
|
||||
MDButton:
|
||||
id: adjuster
|
||||
style: "text"
|
||||
type: ("ADJUSTER", )
|
||||
type: (Type.ADJUSTER, )
|
||||
on_release: app.filter_clients_by_type(self)
|
||||
|
||||
MDButtonIcon:
|
||||
@@ -119,7 +119,7 @@ MDFloatLayout:
|
||||
MDButton:
|
||||
id: misc
|
||||
style: "text"
|
||||
type: ("MISC", )
|
||||
type: (Type.MISC, )
|
||||
on_release: app.filter_clients_by_type(self)
|
||||
|
||||
MDButtonIcon:
|
||||
@@ -140,15 +140,6 @@ MDFloatLayout:
|
||||
|
||||
MDNavigationDrawerDivider:
|
||||
|
||||
MDBoxLayout:
|
||||
orientation: "horizontal"
|
||||
MDIconButton:
|
||||
icon: "alert" if app.failed_worlds else ""
|
||||
theme_text_color: "Custom"
|
||||
text_color: "D23C42"
|
||||
disabled: not app.failed_worlds
|
||||
on_release: app.display_failed()
|
||||
|
||||
|
||||
MDGridLayout:
|
||||
id: main_layout
|
||||
@@ -168,24 +159,3 @@ MDFloatLayout:
|
||||
|
||||
ScrollBox:
|
||||
id: button_layout
|
||||
|
||||
|
||||
<LoadingScreen>:
|
||||
label: label
|
||||
md_bg_color: self.theme_cls.backgroundColor
|
||||
ApAsyncImage:
|
||||
source: Utils.local_path("data/icon.png")
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
size_hint: None, None
|
||||
size: 512, 512
|
||||
opacity: 0.5
|
||||
MDCircularProgressIndicator:
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
size: 550, 550
|
||||
size_hint: None, None
|
||||
MDLabel:
|
||||
id: label
|
||||
text: "Loading..."
|
||||
halign: "center"
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
theme_text_color: "Primary"
|
||||
+5
-5
@@ -5,7 +5,11 @@ local base64 = {}
|
||||
|
||||
local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode
|
||||
if not extract then
|
||||
if _G.bit then -- LuaJIT
|
||||
if _G._VERSION == "Lua 5.4" then
|
||||
extract = load[[return function( v, from, width )
|
||||
return ( v >> from ) & ((1 << width) - 1)
|
||||
end]]()
|
||||
elseif _G.bit then -- LuaJIT
|
||||
local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
|
||||
extract = function( v, from, width )
|
||||
return band( shr( v, from ), shl( 1, width ) - 1 )
|
||||
@@ -23,10 +27,6 @@ if not extract then
|
||||
end
|
||||
return w
|
||||
end
|
||||
else -- Lua 5.3+
|
||||
extract = load[[return function( v, from, width )
|
||||
return ( v >> from ) & ((1 << width) - 1)
|
||||
end]]()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -633,8 +633,8 @@ end)
|
||||
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
||||
print("Must use BizHawk 2.7.0 or newer")
|
||||
else
|
||||
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 11) then
|
||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.11.")
|
||||
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
|
||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
|
||||
end
|
||||
|
||||
if emu.getsystemid() == "NULL" then
|
||||
|
||||
@@ -80,9 +80,6 @@
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
# Gauntlet Legends
|
||||
/worlds/gl/ @jamesbrq
|
||||
|
||||
# Heretic
|
||||
/worlds/heretic/ @Daivuk @KScl
|
||||
|
||||
|
||||
@@ -92,9 +92,8 @@ for setup).
|
||||
|
||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
||||
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
||||
regarding the API can be found in the [world api doc](/docs/world%20api.md), and the [APQuest](/worlds/apquest/) world
|
||||
is a complete world implementation that functions as an introduction to world development. Before publishing, make sure
|
||||
to also check out [world maintainer.md](/docs/world%20maintainer.md).
|
||||
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
|
||||
check out [world maintainer.md](/docs/world%20maintainer.md).
|
||||
|
||||
### Hard Requirements
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ There are also the following optional fields:
|
||||
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
||||
An APWorld without a world_version is always treated as older than one with a version
|
||||
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
||||
* `authors` - a list of authors of the world. Displayed in user-facing places like the Supported Games page
|
||||
on WebHost. Should always be a list of strings.
|
||||
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
|
||||
package managers. Should always be a list of strings.
|
||||
|
||||
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
||||
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
||||
@@ -44,26 +44,6 @@ These get automatically added to the `archipelago.json` of an .apworld if it is
|
||||
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
|
||||
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
||||
|
||||
### Choosing `minimum_ap_version` and `maximum_ap_version`
|
||||
|
||||
Both fields are optional, and most worlds only ever need `minimum_ap_version`.
|
||||
|
||||
* **`minimum_ap_version`** - the most cost-effective approach is to set it to the latest stable Archipelago
|
||||
version when you first create your world, then only raise it when you deliberately start using a new core
|
||||
feature that requires a newer version. When you want such a feature you can choose to either bump
|
||||
`minimum_ap_version`, write code that supports both the old and new core conditionally (for example a
|
||||
`try`/`except` around a moved import), or decide the feature is not worth it and leave it alone. There is
|
||||
usually no need to determine your world's "true" minimum version, since most players run the latest release
|
||||
or close to it.
|
||||
* **`maximum_ap_version`** - rarely needed. Only set it when you already know a particular Archipelago version
|
||||
breaks your world and you cannot quickly fix it or handle the difference conditionally. Most incompatibilities
|
||||
are better resolved by updating the world instead. The main legitimate use case is a world or tool that is
|
||||
tightly coupled to core's generation behavior, where supporting both sides of a breaking change in a single
|
||||
release is not feasible.
|
||||
|
||||
When present, both fields use the same `"major.minor.build"` string format as the Archipelago version itself,
|
||||
for example `"0.6.4"`.
|
||||
|
||||
### "Build APWorlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
|
||||
|
||||
@@ -77,6 +77,15 @@ Changes made to `docker-compose.yaml` can be applied by running `docker compose
|
||||
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
|
||||
|
||||
## Optional: A Link to the Past Enemizer
|
||||
|
||||
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||
error if it is required.
|
||||
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
|
||||
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
|
||||
Enemizer is not currently available for `aarch64`.
|
||||
|
||||
|
||||
## Optional: Git
|
||||
|
||||
Building the image requires a local copy of the ArchipelagoMW source code.
|
||||
|
||||
+14
-22
@@ -220,14 +220,12 @@ Sent to clients to provide what is known as a 'data package' which contains info
|
||||
Sent to clients after a client requested this message be sent to them, more info in the [Bounce](#Bounce) package.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
|----------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| teams | list\[int\] | Optional. Teams this message is targeting. Default assumed value when the key is not present is own team. |
|
||||
| games | list\[str\] | Optional. Game names this message is targeting |
|
||||
| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
|
||||
| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
|
||||
| operator | str | Optional. Specifies whether the "teams", games", "slots" and "tags" conditions are chained via "or", "and" or using the "legacy" operator. |
|
||||
| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| games | list\[str\] | Optional. Game names this message is targeting |
|
||||
| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
|
||||
| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
|
||||
| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
|
||||
|
||||
### InvalidPacket
|
||||
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
||||
@@ -418,22 +416,16 @@ Requests the data package from the server. Does not require client authenticatio
|
||||
| games | list\[str\] | Optional. If specified, will only send back the specified data. Such as, \["Factorio"\] -> Datapackage with only Factorio data. |
|
||||
|
||||
### Bounce
|
||||
Send this message to the server, tell it which clients should receive the message and the server will forward
|
||||
the message to all those targets to which the requirements ("teams", "games", "slots", "tags") apply according
|
||||
to the operator chosen:
|
||||
- "or": Conditions are chained with "or".
|
||||
- "and": Conditions are chained with "and". Important note: If a condition is empty, it evaluates as **True**.
|
||||
- "legacy": Evaluates as `teams and (games or slots or tags)`.
|
||||
Send this message to the server, tell it which clients should receive the message and
|
||||
the server will forward the message to all those targets to which any one requirement applies.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
|----------|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| teams | list\[int\] | Optional. Teams that should receive this message. Defaults to own team. |
|
||||
| games | list\[str\] | Optional. Game names that should receive this message |
|
||||
| slots | list\[int\] | Optional. Player IDs that should receive this message |
|
||||
| tags | list\[str\] | Optional. Client tags that should receive this message |
|
||||
| operator | str | Optional. Controls how the "teams", games", "slots" and "tags" conditions are chained. Options are "or", "and" and "legacy". Default: "legacy". |
|
||||
| data | dict | Optional. Any data you want to send |
|
||||
| Name | Type | Notes |
|
||||
| ------ | ----- | ------ |
|
||||
| games | list\[str\] | Optional. Game names that should receive this message |
|
||||
| slots | list\[int\] | Optional. Player IDs that should receive this message |
|
||||
| tags | list\[str\] | Optional. Client tags that should receive this message |
|
||||
| data | dict | Optional. Any data you want to send |
|
||||
|
||||
### Get
|
||||
Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package.
|
||||
|
||||
+61
-162
@@ -1,7 +1,6 @@
|
||||
# Rule Builder
|
||||
|
||||
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface
|
||||
to define rules and the following advantages:
|
||||
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface to define rules and the following advantages:
|
||||
|
||||
- Rule classes that avoid all the common pitfalls
|
||||
- Logic optimization
|
||||
@@ -13,21 +12,13 @@ to define rules and the following advantages:
|
||||
|
||||
The rule builder consists of 3 main parts:
|
||||
|
||||
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic.
|
||||
They can be combined and take into account your world's options. There are a number of default rules listed below,
|
||||
and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance
|
||||
they must be resolved.
|
||||
2. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules
|
||||
specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly
|
||||
creating these but they'll be created when assigning rules to locations or entrances. These are what power the
|
||||
human-readable logic explanations.
|
||||
3. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from
|
||||
instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
|
||||
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic. They can be combined and take into account your world's options. There are a number of default rules listed below, and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance they must be resolved.
|
||||
1. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly creating these but they'll be created when assigning rules to locations or entrances. These are what power the human-readable logic explanations.
|
||||
1. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
|
||||
|
||||
## Usage
|
||||
|
||||
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule
|
||||
objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
|
||||
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
|
||||
|
||||
```python
|
||||
# In your world's create_regions method
|
||||
@@ -41,7 +32,6 @@ The rule builder comes with a number of rules by default:
|
||||
- `False_`: Always returns false
|
||||
- `And`: Checks that all child rules are true (also provided by `&` operator)
|
||||
- `Or`: Checks that at least one child rule is true (also provided by `|` operator)
|
||||
- `AtLeast`: Checks that at least some count of rules is true
|
||||
- `Has`: Checks that the player has the given item with the given count (default 1)
|
||||
- `HasAll`: Checks that the player has all given items
|
||||
- `HasAny`: Checks that the player has at least one of the given items
|
||||
@@ -50,22 +40,18 @@ The rule builder comes with a number of rules by default:
|
||||
- `HasFromList`: Checks that the player has some number of given items
|
||||
- `HasFromListUnique`: Checks that the player has some number of given items, ignoring duplicates of the same item
|
||||
- `HasGroup`: Checks that the player has some number of items from a given item group
|
||||
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the
|
||||
same item
|
||||
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the same item
|
||||
- `CanReachLocation`: Checks that the player can logically reach the given location
|
||||
- `CanReachRegion`: Checks that the player can logically reach the given region
|
||||
- `CanReachEntrance`: Checks that the player can logically reach the given entrance
|
||||
|
||||
You can combine these rules together to describe the logic required for something. For example, to check if a player
|
||||
either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
|
||||
You can combine these rules together to describe the logic required for something. For example, to check if a player either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
|
||||
|
||||
```python
|
||||
rule = Has("Movement ability") | HasAll("Key 1", "Key 2")
|
||||
```
|
||||
|
||||
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In
|
||||
> order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check
|
||||
> if a rule is defined you must use `if rule is not None`.
|
||||
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check if a rule is defined you must use `if rule is not None`.
|
||||
|
||||
### Assigning rules
|
||||
|
||||
@@ -75,16 +61,13 @@ When assigning the rule you must use the `set_rule` helper to correctly resolve
|
||||
self.set_rule(location_or_entrance, rule)
|
||||
```
|
||||
|
||||
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the
|
||||
entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify
|
||||
`force_creation=True` if you would like to create the entrance even if the rule is `False`.
|
||||
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify `force_creation=True` if you would like to create the entrance even if the rule is `False`.
|
||||
|
||||
```python
|
||||
self.create_entrance(from_region, to_region, rule)
|
||||
```
|
||||
|
||||
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify
|
||||
> the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
|
||||
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
|
||||
|
||||
You can also set a rule for your world's completion condition:
|
||||
|
||||
@@ -94,42 +77,21 @@ self.set_completion_rule(rule)
|
||||
|
||||
### Restricting options
|
||||
|
||||
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an
|
||||
iterable of `OptionFilter` instances. When resolved, if no filters are provided or all of them pass then the rule will
|
||||
resolve as normal. Otherwise, the rule will be replaced with `True` or `False` depending on what `filtered_resolution`
|
||||
is set to, which defaults to `False`.
|
||||
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an iterable of `OptionFilter` instances. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
|
||||
|
||||
```python
|
||||
rule1 = Has(
|
||||
"Fast Travel Spell",
|
||||
options=[OptionFilter(RandoFastTravel, RandoFastTravel.option_true)],
|
||||
)
|
||||
rule2 = Has(
|
||||
"Starting Party Member",
|
||||
options=[OptionFilter(RandoParty, 1)], # option attributes are suggested but any value works
|
||||
filtered_resolution=True,
|
||||
)
|
||||
```
|
||||
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
|
||||
|
||||
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are
|
||||
allowed:
|
||||
- `eq`: `==`
|
||||
- `ne`: `!=`
|
||||
- `gt`: `>`
|
||||
- `lt`: `<`
|
||||
- `ge`: `>=`
|
||||
- `le`: `<=`
|
||||
- `contains`: `in`
|
||||
|
||||
- `eq`: `option_value == filter_value`
|
||||
- `ne`: `option_value != filter_value`
|
||||
- `gt`: `option_value > filter_value`
|
||||
- `lt`: `option_value < filter_value`
|
||||
- `ge`: `option_value >= filter_value`
|
||||
- `le`: `option_value <= filter_value`
|
||||
- `in`: `option_value in filter_value`
|
||||
- `contains`: `filter_value in option_value` (note reversed operands)
|
||||
By default rules that are excluded by their options will default to `False`. If you want to default to `True` instead, you can specify `filtered_resolution=True` on your rule.
|
||||
|
||||
```python
|
||||
rule1 = Has("Movement Ability", options=[OptionFilter(SkipsLevel, SkipsLevel.option_hard, operator="lt")])
|
||||
rule2 = Has("Item", options=[OptionFilter(ChoiceOption, [1, 5], operator="in")])
|
||||
```
|
||||
|
||||
To check if the player has received the switch item if switches are randomized, or if they can reach the switch when not
|
||||
randomized:
|
||||
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
|
||||
|
||||
```python
|
||||
rule = (
|
||||
@@ -153,12 +115,12 @@ If you would like to provide option filters when reusing or composing rules, you
|
||||
common_rule = Has("A") | HasAny("B", "C")
|
||||
...
|
||||
rule = (
|
||||
Filtered(common_rule, options=[OptionFilter(Opt, 0)])
|
||||
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)])
|
||||
Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
|
||||
| Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
|
||||
)
|
||||
```
|
||||
|
||||
For convenience, you can also use the `&` and `|` operators to apply options to rules:
|
||||
You can also use the & and | operators to apply options to rules:
|
||||
|
||||
```python
|
||||
common_rule = Has("A")
|
||||
@@ -167,22 +129,14 @@ common_rule_only_on_easy = common_rule & easy_filter
|
||||
common_rule_skipped_on_easy = common_rule | easy_filter
|
||||
```
|
||||
|
||||
Combining the above, you can easily bypass a requirement based on option choices:
|
||||
|
||||
```python
|
||||
rule = Has("Some Upgrade") | OptionFilter(CombatDifficulty, CombatDifficulty.option_medium, operator="ge")
|
||||
```
|
||||
|
||||
### Field resolvers
|
||||
|
||||
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a
|
||||
`FieldResolver` to define how to populate that field when the rule is being resolved.
|
||||
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a `FieldResolver` to define how to populate that field when the rule is being resolved.
|
||||
|
||||
There are two build-in field resolvers:
|
||||
|
||||
- `FromOption`: Resolves to the value of the given option
|
||||
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get
|
||||
a nested attribute or dict item
|
||||
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get a nested attribute or dict item
|
||||
|
||||
```python
|
||||
world.options.mcguffin_count = 5
|
||||
@@ -194,8 +148,7 @@ rule = (
|
||||
# Results in Has("A", count=5) | HasGroup("Important items", count=99)
|
||||
```
|
||||
|
||||
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and
|
||||
implements a `resolve` function:
|
||||
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and implements a `resolve` function:
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@@ -210,30 +163,24 @@ class FromCustomResolution(FieldResolver, game="MyGame"):
|
||||
rule = Has("Combat Level", count=FromCustomResolution("combat"))
|
||||
```
|
||||
|
||||
If you want to support rule serialization and your resolver contains non-serializable properties you may need to
|
||||
override `to_dict` or `from_dict`.
|
||||
If you want to support rule serialization and your resolver contains non-serializable properties you may need to override `to_dict` or `from_dict`.
|
||||
|
||||
## Enabling caching
|
||||
|
||||
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your
|
||||
rules.
|
||||
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
|
||||
|
||||
```python
|
||||
class MyWorld(CachedRuleBuilderWorld):
|
||||
game = "My Game"
|
||||
```
|
||||
|
||||
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead
|
||||
cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
|
||||
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
|
||||
|
||||
### Item name mapping
|
||||
|
||||
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps
|
||||
actual item names to real item names so the cache system knows what to invalidate.
|
||||
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps actual item names to real item names so the cache system knows what to invalidate.
|
||||
|
||||
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical
|
||||
`Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical
|
||||
`Currency`.
|
||||
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`.
|
||||
|
||||
```python
|
||||
class MyWorld(CachedRuleBuilderWorld):
|
||||
@@ -247,13 +194,9 @@ class MyWorld(CachedRuleBuilderWorld):
|
||||
|
||||
## Defining custom rules
|
||||
|
||||
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide
|
||||
the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and
|
||||
to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
|
||||
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
|
||||
|
||||
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically
|
||||
be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more
|
||||
dependencies functions as outlined below.
|
||||
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more dependencies functions as outlined below.
|
||||
|
||||
To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement:
|
||||
|
||||
@@ -302,10 +245,7 @@ class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
|
||||
|
||||
### Item dependencies
|
||||
|
||||
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of
|
||||
your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id
|
||||
of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this
|
||||
function even when caching is disabled as more things may use it in the future.
|
||||
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this function even when caching is disabled as more things may use it in the future.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
@@ -322,10 +262,7 @@ All of the default `Has*` rules define this function already.
|
||||
|
||||
### Region dependencies
|
||||
|
||||
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of
|
||||
region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
|
||||
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
|
||||
caching system if applicable.
|
||||
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
@@ -342,10 +279,7 @@ The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules d
|
||||
|
||||
### Location dependencies
|
||||
|
||||
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping
|
||||
of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
|
||||
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
|
||||
caching system if applicable.
|
||||
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
@@ -362,10 +296,7 @@ The default `CanReachLocation` rule defines this function already.
|
||||
|
||||
### Entrance dependencies
|
||||
|
||||
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping
|
||||
of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
|
||||
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
|
||||
caching system if applicable.
|
||||
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
|
||||
|
||||
```python
|
||||
@dataclasses.dataclass()
|
||||
@@ -382,13 +313,9 @@ The default `CanReachEntrance` rule defines this function already.
|
||||
|
||||
### Rule explanations
|
||||
|
||||
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally
|
||||
accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will
|
||||
display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is
|
||||
useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
|
||||
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
|
||||
|
||||
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your
|
||||
`Resolved` class:
|
||||
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class:
|
||||
|
||||
```python
|
||||
class MyRule(Rule, game="My Game"):
|
||||
@@ -425,35 +352,22 @@ class MyRule(Rule, game="My Game"):
|
||||
|
||||
### Cache control
|
||||
|
||||
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two
|
||||
class attributes on the `Resolved` class you can override to change this behavior.
|
||||
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two class attributes on the `Resolved` class you can override to change this behavior.
|
||||
|
||||
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and
|
||||
always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will
|
||||
cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your
|
||||
rule should be marked as stale.
|
||||
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when
|
||||
being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still
|
||||
define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the
|
||||
overhead of the caching system will slow it down.
|
||||
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your rule should be marked as stale.
|
||||
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the overhead of the caching system will slow it down.
|
||||
|
||||
### Caveats
|
||||
|
||||
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if
|
||||
your world has opted into caching.
|
||||
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if your world has opted into caching.
|
||||
- Resolved rules are forced to be frozen dataclasses. They and all their attributes must be immutable and hashable.
|
||||
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved`
|
||||
instances directly.
|
||||
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly.
|
||||
|
||||
## Serialization
|
||||
|
||||
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the
|
||||
rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and
|
||||
entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
|
||||
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
|
||||
|
||||
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and
|
||||
an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule
|
||||
would look like:
|
||||
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule would look like:
|
||||
|
||||
```python
|
||||
{
|
||||
@@ -466,8 +380,7 @@ would look like:
|
||||
}
|
||||
```
|
||||
|
||||
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in
|
||||
the same serializable format:
|
||||
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in the same serializable format:
|
||||
|
||||
```python
|
||||
{
|
||||
@@ -551,8 +464,7 @@ class BasicLogicRule(Rule, game="My Game"):
|
||||
}
|
||||
```
|
||||
|
||||
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it
|
||||
correctly:
|
||||
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it correctly:
|
||||
|
||||
```python
|
||||
class BasicLogicRule(Rule, game="My Game"):
|
||||
@@ -573,14 +485,10 @@ These are properties and helpers that are available to you in your world.
|
||||
#### Methods
|
||||
|
||||
- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation
|
||||
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the
|
||||
inherited dependencies, gets called automatically after set_rules
|
||||
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given
|
||||
location or entrance
|
||||
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies, gets called automatically after set_rules
|
||||
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance
|
||||
- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world
|
||||
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`:
|
||||
Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates
|
||||
to `False_()` unless force_creation is `True`
|
||||
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` unless force_creation is `True`
|
||||
|
||||
#### CachedRuleBuilderWorld Properties
|
||||
|
||||
@@ -593,27 +501,18 @@ The following property is only available when inheriting from `CachedRuleBuilder
|
||||
These are properties and helpers that you can use or override for custom rules.
|
||||
|
||||
- `_instantiate(world: World)`: Create a new resolved rule instance, override for custom rules as required
|
||||
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's
|
||||
serialization
|
||||
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if
|
||||
you've overridden `to_dict`
|
||||
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's serialization
|
||||
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if you've overridden `to_dict`
|
||||
- `__str__()`: Basic string representation of a rule, useful for debugging
|
||||
|
||||
#### Resolved rule API
|
||||
|
||||
- `player: int`: The slot this rule is resolved for
|
||||
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for
|
||||
this rule
|
||||
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item
|
||||
collection
|
||||
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching
|
||||
regions
|
||||
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on
|
||||
reaching locations
|
||||
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on
|
||||
reaching entrances
|
||||
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic
|
||||
(and if state is defined its evaluation) in a human readable way, override to explain custom rules
|
||||
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is
|
||||
defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
|
||||
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for this rule
|
||||
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item collection
|
||||
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching regions
|
||||
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on reaching locations
|
||||
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on reaching entrances
|
||||
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules
|
||||
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
|
||||
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules
|
||||
@@ -78,6 +78,16 @@ first generate the binary distribution and then run `python setup.py bdist_appim
|
||||
put an `appimagetool` into the directory you run the command from, rename it to `appimagetool` and make it executable.
|
||||
|
||||
|
||||
## Optional: A Link to the Past Enemizer
|
||||
|
||||
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||
error if it is required.
|
||||
|
||||
You can get the latest Enemizer release at [Enemizer Github releases](https://github.com/Ijwu/Enemizer/releases).
|
||||
It should be dropped as "EnemizerCLI" into the root folder of the project. Alternatively, you can point the Enemizer
|
||||
setting in host.yaml at your Enemizer executable.
|
||||
|
||||
|
||||
## Optional: SNI
|
||||
|
||||
[SNI](https://github.com/alttpo/sni/blob/main/README.md) is required to use SNIClient. If not integrated into the project, it has to be started manually.
|
||||
|
||||
+1
-17
@@ -131,32 +131,16 @@ Unless you configured PyCharm to use pytest as a test runner, you may get import
|
||||
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
|
||||
|
||||
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
|
||||
Your working directory should be the root Archipelago directory and the script should be the
|
||||
Your working directory should be the directory of your world in the worlds directory and the script should be the
|
||||
tests folder within your world.
|
||||
|
||||
You can also find the 'Archipelago Unittests' as an option in the dropdown at the top of the window
|
||||
next to the run and debug buttons.
|
||||
|
||||
To run the suite scoped to a single world, use the shared **APQuest Tests** run configuration in the dropdown.
|
||||
To test your own world, duplicate it in *Edit Configurations…* and change the `AP_TEST_WORLDS` environment
|
||||
variable to your world's folder name.
|
||||
|
||||
#### Running Tests without Pycharm
|
||||
|
||||
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
|
||||
|
||||
#### Running Tests for Specific Worlds
|
||||
|
||||
Set the `AP_TEST_WORLDS` environment variable to a comma-separated list of world **folder** names to scope a run
|
||||
to just those worlds:
|
||||
|
||||
```
|
||||
AP_TEST_WORLDS=apquest pytest
|
||||
```
|
||||
|
||||
Pass several worlds with a comma, e.g. `AP_TEST_WORLDS=apquest,pokemon_emerald`. Add
|
||||
`--continue-on-collection-errors` if your environment is missing the webhost requirements (`flask`, etc.).
|
||||
|
||||
#### Running Tests Multithreaded
|
||||
|
||||
pytest can run multiple test runners in parallel with the pytest-xdist extension.
|
||||
|
||||
@@ -17,12 +17,6 @@
|
||||
# Web hosting port
|
||||
#PORT: 80
|
||||
|
||||
# Ports used for game hosting. Values can be specific ports, port ranges or both. Default is: [49152-65535, 0]
|
||||
# Zero means it will use a random free port if there is no free port in the ranges specified
|
||||
# Examples of valid values: [40000-41000, 49152-65535]
|
||||
# If ports within the range(s) are already in use, the WebHost will fallback to the default [49152-65535, 0] range.
|
||||
#GAME_PORTS: [49152-65535, 0]
|
||||
|
||||
# Place where uploads go.
|
||||
#UPLOAD_FOLDER: uploads
|
||||
|
||||
|
||||
+2
-13
@@ -234,11 +234,6 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
|
||||
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
|
||||
required, and will prevent progression and useful items from being placed at excluded locations.
|
||||
|
||||
By default, progression found at a location advances progression balancing to a new sphere. Set the location's
|
||||
`progression_balancing_sphere` attribute to `False` to collect its progression while expanding the current balancing
|
||||
sphere instead. This can be useful for local progression that opens more of the same logical area, such as dungeon keys
|
||||
that cannot leave their original dungeon.
|
||||
|
||||
### Items
|
||||
|
||||
Items are all things that can "drop" for your game. This may be RPG items like weapons, or technologies you normally
|
||||
@@ -261,9 +256,8 @@ Other classifications include:
|
||||
* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with
|
||||
another flag like "progression", it means "an especially useful progression item".
|
||||
* `trap`: negative impact on the player
|
||||
* `skip_balancing`: denotes that an item should not be moved by progression balancing. On progression items, it prevents
|
||||
the item from being moved to an earlier sphere. On non-progression items, it prevents the item from being displaced
|
||||
into a later sphere.
|
||||
* `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be
|
||||
combined with `progression`; see below)
|
||||
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
|
||||
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
|
||||
* `deprioritized`: denotes that an item should not be placed on priority locations
|
||||
@@ -333,11 +327,6 @@ reject the placement of an item there.
|
||||
|
||||
### Events (or "generation-only items/locations")
|
||||
|
||||
> **Warning:** If you're trying to tell the Archipelago server that the player has achieved their goal, you want to send
|
||||
a [StatusUpdate packet](network%20protocol.md#statusupdate), or however [your client library](network%20protocol.md)
|
||||
wraps it. Despite the popularity of "victory events" during generation, events have nothing to do with how goals are
|
||||
triggered during gameplay.
|
||||
|
||||
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
|
||||
Event locations can never be checked by the player, and event items cannot be received during play.
|
||||
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
general_options:
|
||||
# Where to place output files
|
||||
output_path: "output"
|
||||
# Options for MultiServer
|
||||
# Null means nothing, for the server this means to default the value
|
||||
# These overwrite command line arguments!
|
||||
server_options:
|
||||
host: null
|
||||
port: 38281
|
||||
password: null
|
||||
multidata: null
|
||||
savefile: null
|
||||
disable_save: false
|
||||
loglevel: "info"
|
||||
logtime: false
|
||||
# Allows for clients to log on and manage the server. If this is null, no remote administration is possible.
|
||||
server_password: null
|
||||
# Disallow !getitem
|
||||
disable_item_cheat: false
|
||||
# Client hint system
|
||||
# Points given to a player for each acquired item in their world
|
||||
location_check_points: 1
|
||||
# Relative point cost to receive a hint via !hint for players
|
||||
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint,
|
||||
# for a total of 5
|
||||
hint_cost: 10
|
||||
# Release modes
|
||||
# A Release sends out the remaining items *from* a world that releases
|
||||
# "disabled" -> clients can't release,
|
||||
# "enabled" -> clients can always release
|
||||
# "auto" -> automatic release on goal completion
|
||||
# "auto-enabled" -> automatic release on goal completion and manual release is also enabled
|
||||
# "goal" -> release is allowed after goal completion
|
||||
release_mode: "auto"
|
||||
# Collect modes
|
||||
# A Collect sends the remaining items *to* a world that collects
|
||||
# "disabled" -> clients can't collect,
|
||||
# "enabled" -> clients can always collect
|
||||
# "auto" -> automatic collect on goal completion
|
||||
# "auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
|
||||
# "goal" -> collect is allowed after goal completion
|
||||
collect_mode: "auto"
|
||||
# Remaining modes
|
||||
# !remaining handling, that tells a client which items remain in their pool
|
||||
# "enabled" -> Client can always ask for remaining items
|
||||
# "disabled" -> Client can never ask for remaining items
|
||||
# "goal" -> Client can ask for remaining items after goal completion
|
||||
remaining_mode: "goal"
|
||||
# Countdown modes
|
||||
# Determines whether or not a player can initiate a countdown with !countdown
|
||||
# Note that /countdown is always available to the host.
|
||||
# "enabled" -> Client can always initiate a countdown with !countdown.
|
||||
# "disabled" -> Client can never initiate a countdown with !countdown.
|
||||
# "auto" -> !countdown will be available for any room with less than 30 slots.
|
||||
countdown_mode: "auto"
|
||||
# Automatically shut down the server after this many seconds without new location checks, 0 to keep running
|
||||
auto_shutdown: 0
|
||||
# Compatibility handling
|
||||
# 2 -> Recommended for casual/cooperative play, attempt to be compatible with everything across all versions
|
||||
# 1 -> No longer in use, kept reserved in case of future use
|
||||
# 0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
|
||||
compatibility: 2
|
||||
# log all server traffic, mostly for dev use
|
||||
log_network: 0
|
||||
# Options for Generation
|
||||
generator:
|
||||
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
|
||||
enemizer_path: "EnemizerCLI/EnemizerCLI.Core"
|
||||
# Folder from which the player yaml files are pulled from
|
||||
player_files_path: "Players"
|
||||
# amount of players, 0 to infer from player files
|
||||
players: 0
|
||||
# general weights file, within the stated player_files_path location
|
||||
# gets used if players is higher than the amount of per-player files found to fill remaining slots
|
||||
weights_file_path: "weights.yaml"
|
||||
# Meta file name, within the stated player_files_path location
|
||||
meta_file_path: "meta.yaml"
|
||||
# Create a spoiler file
|
||||
# 0 -> None
|
||||
# 1 -> Spoiler without playthrough or paths to playthrough required items
|
||||
# 2 -> Spoiler with playthrough (viable solution to goals)
|
||||
# 3 -> Spoiler with playthrough and traversal paths towards items
|
||||
spoiler: 3
|
||||
# Create encrypted race roms and flag games as race mode
|
||||
race: 0
|
||||
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||
# Available options: bosses, items, texts, connections
|
||||
plando_options: "bosses, connections, texts"
|
||||
# What to do if the current item placements appear unsolvable.
|
||||
# raise -> Raise an exception and abort.
|
||||
# swap -> Attempt to fix it by swapping prior placements around. (Default)
|
||||
# start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
|
||||
panic_method: "swap"
|
||||
loglevel: "info"
|
||||
logtime: false
|
||||
sni_options:
|
||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||
sni_path: "SNI"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
snes_rom_start: true
|
||||
bizhawkclient_options:
|
||||
# The location of the EmuHawk you want to auto launch patched ROMs with
|
||||
emuhawk_path: "None"
|
||||
# Set this to true to autostart a patched ROM in BizHawk with the connector script,
|
||||
# to false to never open the patched rom automatically,
|
||||
# or to a path to an external program to open the ROM file with that instead.
|
||||
rom_start: true
|
||||
adventure_options:
|
||||
# File name of the standard NTSC Adventure rom.
|
||||
# The licensed "The 80 Classic Games" CD-ROM contains this.
|
||||
# It may also have a .a26 extension
|
||||
rom_file: "roms/ADVNTURE.BIN"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program for '.a26'
|
||||
# Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
|
||||
rom_start: true
|
||||
# Optional, additional args passed into rom_start before the .bin file
|
||||
# For example, this can be used to autoload the connector script in BizHawk
|
||||
# (see BizHawk --lua= option)
|
||||
# Windows example:
|
||||
# rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
|
||||
rom_args: " "
|
||||
# Set this to true to display item received messages in EmuHawk
|
||||
display_msgs: true
|
||||
ape_escape_3_options:
|
||||
# Preferences for game session management.
|
||||
# > save_state_on_room_transition: Automatically create a save state when transitioning between rooms.
|
||||
# > save_state_on_item_received: Automatically create a save state when receiving a new progressive item.
|
||||
# > save_state_on_location_check: Automatically create a save state when checking a new location.
|
||||
# > load_state_on_connect: Load a state automatically after connecting to the multiworld if the client
|
||||
# is already connected to the game and that the last save is from a save state and not a normal game save.
|
||||
save_state_on_room_transition: false
|
||||
save_state_on_item_received: true
|
||||
save_state_on_location_check: false
|
||||
load_state_on_connect: false
|
||||
# Preferences for game/client-enforcement behavior
|
||||
# > auto-equip : Automatically assign received gadgets to a face button
|
||||
auto_equip: true
|
||||
# Preferences for game generation. Only relevant for world generation and not the setup of or during play.
|
||||
# > whitelist_pgc_bypass: Allow Ape Escape 3 players to enable "PGC Bypass" as a possible outcome for
|
||||
# Lucky Ticket Consolation Prize.
|
||||
# > whitelist_instant_goal: Allow Ape Escape 3 players to enable "Instant Goal" as a possible outcome for
|
||||
# Lucky Ticket Consolation Prize.
|
||||
whitelist_pgc_bypass: false
|
||||
whitelist_instant_goal: false
|
||||
banjo_tooie_options:
|
||||
# File path of the Banjo-Tooie (USA) ROM.
|
||||
rom_path: ""
|
||||
# Folder path of where to save the patched ROM.
|
||||
patch_path: ""
|
||||
# File path of the program to automatically run.
|
||||
# Leave blank to disable.
|
||||
program_path: ""
|
||||
# Arguments to pass to the automatically run program.
|
||||
# Leave blank to disable.
|
||||
# Set to "--lua=" to automatically use the correct path for the lua connector.
|
||||
program_args: "--lua="
|
||||
# No idea
|
||||
clair_obscur_options:
|
||||
{}
|
||||
cv64_options:
|
||||
# File name of the CV64 US 1.0 rom
|
||||
rom_file: "roms/Castlevania (USA).z64"
|
||||
cv_dos_options:
|
||||
# File name of the Castlevania: Dawn of Sorrow ROM file.
|
||||
rom_file: "roms/CASTLEVANIA1_ACVEA4_00.nds"
|
||||
cvcotm_options:
|
||||
# File name of the Castlevania CotM US rom
|
||||
rom_file: "roms/Castlevania - Circle of the Moon (USA).gba"
|
||||
cvhodis_options:
|
||||
# File name of the Castlevania HoD US rom
|
||||
rom_file: "roms/Castlevania - Harmony of Dissonance (USA).gba"
|
||||
cvlod_options:
|
||||
# File name of the CVLoD US rom
|
||||
rom_file: "Castlevania - Legacy of Darkness (USA).z64"
|
||||
# Settings for the DK64 randomizer.
|
||||
dk64_options:
|
||||
# Choose the release version of the DK64 randomizer to use.
|
||||
# By setting it to master (Default) you will always pull the latest stable version.
|
||||
# By setting it to dev you will pull the latest development version.
|
||||
# If you want a specific version, you can set it to a AP version number eg: v1.0.45
|
||||
release_branch: "master"
|
||||
dkc2_options:
|
||||
# File name of the Donkey Kong Country 2 US v1.1 ROM
|
||||
rom_file: "roms/Donkey Kong Country 2 - Diddy's Kong Quest (USA).sfc"
|
||||
# Path to the user's Donkey Kong Country 2 Poptracker Pack.
|
||||
ut_poptracker_path: ""
|
||||
# Folder path of the trivia database
|
||||
# Preferably point it to /data/trivia/dkc2/
|
||||
trivia_path: "data/trivia/dkc2"
|
||||
dkc3_options:
|
||||
# File name of the DKC3 US rom
|
||||
rom_file: "roms/Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||
earthbound_options:
|
||||
# File name of the EarthBound US ROM
|
||||
rom_file: "roms/EarthBound.sfc"
|
||||
factorio_options:
|
||||
executable: "factorio/bin/x64/factorio"
|
||||
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
||||
# server_settings: "factorio\\data\\server-settings.json"
|
||||
server_settings: null
|
||||
# Whether to filter item send messages displayed in-game to only those that involve you.
|
||||
filter_item_sends: false
|
||||
# Whether to filter connection changes displayed in-game.
|
||||
filter_connection_changes: false
|
||||
# Whether to send chat messages from players on the Factorio server to Archipelago.
|
||||
bridge_chat_out: true
|
||||
fe8_settings:
|
||||
# File name of your Fire Emblem: The Sacred Stones (U) ROM
|
||||
rom_file: "roms/Fire Emblem The Sacred Stones (U).gba"
|
||||
ffr_options:
|
||||
display_msgs: true
|
||||
gauntletlegends_options:
|
||||
# The location of your Retroarch folder
|
||||
retroarch_path: "None"
|
||||
# File name of the GL US rom
|
||||
rom_file: "roms/Gauntlet Legends (U) [!].z64"
|
||||
rom_start: true
|
||||
glover_options:
|
||||
# File path of the Glover (USA) ROM.
|
||||
rom_path: ""
|
||||
# Folder path of where to save the patched ROM.
|
||||
patch_path: ""
|
||||
# File path of the program to automatically run.
|
||||
# Leave blank to disable.
|
||||
program_path: ""
|
||||
# Arguments to pass to the automatically run program.
|
||||
# Leave blank to disable.
|
||||
# Set to "--lua=" to automatically use the correct path for the lua connector.
|
||||
program_args: "--lua="
|
||||
gstla_options:
|
||||
# File name of the GS TLA UE Rom
|
||||
rom_file: "roms/Golden Sun - The Lost Age (UE) [!].gba"
|
||||
hades_options:
|
||||
# Path to the StyxScribe install
|
||||
styx_scribe_path: "C:/Program Files/Steam/steamapps/common/Hades/StyxScribe.py"
|
||||
hk_options:
|
||||
# Disallows the APMapMod from showing spoiler placements.
|
||||
disable_spoilers: false
|
||||
jakanddaxter_options:
|
||||
# Path to folder containing the ArchipelaGOAL mod executables (gk.exe and goalc.exe).
|
||||
# Ensure this path contains forward slashes (/) only. This setting only applies if
|
||||
# Auto Detect Root Directory is set to false.
|
||||
root_directory: "%programfiles%/OpenGOAL-Launcher/features/jak1/mods/JakMods/archipelagoal"
|
||||
# Attempt to find the OpenGOAL installation and the mod executables (gk.exe and goalc.exe)
|
||||
# automatically. If set to true, the ArchipelaGOAL Root Directory setting is ignored.
|
||||
auto_detect_root_directory: true
|
||||
# Enforce friendly player options in both single and multiplayer seeds. Disabling this allows for
|
||||
# more disruptive and challenging options, but may impact seed generation. Use at your own risk!
|
||||
enforce_friendly_options: true
|
||||
k64_options:
|
||||
# File name of the K64 EN rom
|
||||
rom_file: "roms/Kirby 64 - The Crystal Shards (USA).z64"
|
||||
kdl3_options:
|
||||
# File name of the KDL3 JP or EN rom
|
||||
rom_file: "roms/Kirby's Dream Land 3.sfc"
|
||||
ladx_options:
|
||||
# File name of the Link's Awakening DX rom
|
||||
rom_file: "roms/Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .gbc file with
|
||||
# Examples:
|
||||
# Retroarch:
|
||||
# rom_start: "C:/RetroArch-Win64/retroarch.exe -L sameboy"
|
||||
# BizHawk:
|
||||
# rom_start: "C:/BizHawk-2.9-win-x64/EmuHawk.exe --lua=data/lua/connector_ladx_bizhawk.lua"
|
||||
rom_start: true
|
||||
# Gfxmod file, get it from upstream: https://github.com/daid/LADXR/tree/master/gfx
|
||||
# Only .bin or .bdiff files
|
||||
# The same directory will be checked for a matching text modification file
|
||||
gfx_mod_file: ""
|
||||
lttp_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "roms/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
lufia2ac_options:
|
||||
# File name of the US rom
|
||||
rom_file: "roms/Lufia II - Rise of the Sinistrals (USA).sfc"
|
||||
messenger_settings:
|
||||
game_path: "TheMessenger.exe"
|
||||
metroidzeromission_options:
|
||||
# File name of the Metroid: Zero Mission ROM.
|
||||
rom_file: "roms/Metroid - Zero Mission (USA).gba"
|
||||
# Set this to false to never autostart a rom (such as after patching),
|
||||
# Set it to true to have the operating system default program open the rom
|
||||
# Alternatively, set it to a path to a program to open the .gba file with
|
||||
rom_start: true
|
||||
mk64_options:
|
||||
# File name of the MK64 ROM
|
||||
rom_file: "roms/Mario Kart 64 (U) [!].z64"
|
||||
metroidfusion_options:
|
||||
# File name of the Metroid Fusion ROM
|
||||
rom_file: "roms/Metroid Fusion (USA).gba"
|
||||
rom_start: true
|
||||
display_location_found_messages: true
|
||||
mlss_options:
|
||||
# File name of the MLSS US rom
|
||||
rom_file: "roms/Mario & Luigi - Superstar Saga (U).gba"
|
||||
rom_start: true
|
||||
mm2_options:
|
||||
# File name of the MM2 EN rom
|
||||
rom_file: "roms/Mega Man 2 (USA).nes"
|
||||
mmbn3_options:
|
||||
# File name of the MMBN3 Blue US rom
|
||||
rom_file: "roms/Mega Man Battle Network 3 - Blue Version (USA).gba"
|
||||
# Set this to false to never autostart a rom (such as after patching),
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .gba file with
|
||||
rom_start: true
|
||||
mzm_options:
|
||||
rom_file: "roms/Metroid - Zero Mission (USA).gba"
|
||||
rom_start: true
|
||||
oot_options:
|
||||
# File name of the OoT v1.0 ROM
|
||||
rom_file: "roms/The Legend of Zelda - Ocarina of Time.z64"
|
||||
# Set this to false to never autostart a rom (such as after patching),
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .z64 file with
|
||||
rom_start: true
|
||||
paper_mario_settings:
|
||||
# File name of the Paper Mario USA ROM
|
||||
rom_file: "roms/Paper Mario (USA).z64"
|
||||
# Set this to false to never autostart a rom (such as after patching),
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .z64 file with
|
||||
rom_start: true
|
||||
papermariottyd_options:
|
||||
# The location of the Dolphin you want to auto launch patched ROMs with
|
||||
dolphin_path: "None"
|
||||
# File name of the TTYD US iso
|
||||
rom_file: "roms/Paper Mario - The Thousand-Year Door (USA).iso"
|
||||
rom_start: true
|
||||
pmd_eos_options:
|
||||
# File name of the EoS EU rom
|
||||
rom_file: "roms/POKEDUN_SORA_C2SP01_00.nds"
|
||||
rom_start: true
|
||||
pokemon_bw_settings:
|
||||
# File name of your Pokémon Black Version ROM
|
||||
black_rom: "PokemonBlack.nds"
|
||||
# File name of your Pokémon White Version ROM
|
||||
white_rom: "PokemonWhite.nds"
|
||||
# Toggles whether Encounter Plando is enabled for players in generation.
|
||||
# If disabled, yamls that use Encounter Plando do not raise OptionErrors, but display a warning.
|
||||
enable_encounter_plando: true
|
||||
# If enabled, files inside the rom that are changed as part of the patching process (except for base patches)
|
||||
# will be dumped into a zip file next to the patched rom (for debug purposes).
|
||||
dump_patched_files: false
|
||||
pokemon_crystal_settings:
|
||||
rom_file: "roms/Pokemon - Crystal Version (UE) [C][!].gbc"
|
||||
pokemon_emerald_settings:
|
||||
# File name of your English Pokemon Emerald ROM
|
||||
rom_file: "roms/Pokemon - Emerald Version (USA, Europe).gba"
|
||||
pokemon_frlg_settings:
|
||||
# File name of your English Pokémon FireRed ROM
|
||||
firered_rom_file: "roms/Pokemon - FireRed Version (USA, Europe).gba"
|
||||
# File name of your English Pokémon LeafGreen ROM
|
||||
leafgreen_rom_file: "roms/Pokemon - LeafGreen Version (USA, Europe).gba"
|
||||
ut_poptracker_path: ""
|
||||
pokemon_platinum_settings:
|
||||
rom_file: "roms/pokeplatinum.nds"
|
||||
pokemon_rb_options:
|
||||
# File names of the Pokemon Red and Blue roms
|
||||
red_rom_file: "roms/Pokemon Red (UE) [S][!].gb"
|
||||
blue_rom_file: "roms/Pokemon Blue (UE) [S][!].gb"
|
||||
pokepinball_settings:
|
||||
# File name of the Pokemon Pinball Color US rom
|
||||
rom_file: "roms/PokemonPinball.gbc"
|
||||
portal2_options:
|
||||
# The file path of the extras.txt file (used to generate the menu in game)
|
||||
menu_file: "C:\\Program Files (x86)\\Steam\\steamapps\\sourcemods\\Portal2Archipelago\\scripts\\extras.txt"
|
||||
# The port set in the portal 2 launch options e.g. 3000
|
||||
default_portal2_port: 3000
|
||||
saving_princess_settings:
|
||||
# Path to the game executable from which files are extracted
|
||||
exe_path: "Saving Princess.exe"
|
||||
# Path to the mod installation folder
|
||||
install_folder: "Saving Princess"
|
||||
# Set this to false to never autostart the game
|
||||
launch_game: true
|
||||
# The console command that will be used to launch the game
|
||||
# The command will be executed with the installation folder as the current directory
|
||||
launch_command: "wine \"Saving Princess v0_8.exe\""
|
||||
sc2_options:
|
||||
# The starting width the client window in pixels
|
||||
window_width: 1080
|
||||
# The starting height the client window in pixels
|
||||
window_height: 720
|
||||
# Controls whether the game should start in windowed mode
|
||||
game_windowed_mode: false
|
||||
# If set to true, in-client scouting will show traps as distinct from filler
|
||||
show_traps: false
|
||||
# Overrides the disable forced-camera slot option. Possible values: `true`, `false`, `default`. Default uses slot value
|
||||
disable_forced_camera: "default"
|
||||
# Overrides the skip cutscenes slot option. Possible values: `true`, `false`, `default`. Default uses slot value
|
||||
skip_cutscenes: "default"
|
||||
# Overrides the slot's difficulty setting. Possible values: `casual`, `normal`, `hard`, `brutal`, `default`. Default uses slot value
|
||||
game_difficulty: "default"
|
||||
# Overrides the slot's gamespeed setting. Possible values: `slower`, `slow`, `normal`, `fast`, `faster`, `default`. Default uses slot value
|
||||
game_speed: "default"
|
||||
# Defines the colour of terran mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
|
||||
terran_button_color:
|
||||
- 0.0838
|
||||
- 0.2898
|
||||
- 0.2346
|
||||
# Defines the colour of zerg mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
|
||||
zerg_button_color:
|
||||
- 0.345
|
||||
- 0.22425
|
||||
- 0.12765
|
||||
# Defines the colour of protoss mission buttons in the launcher in rgb format (3 elements ranging from 0 to 1)
|
||||
protoss_button_color:
|
||||
- 0.18975
|
||||
- 0.2415
|
||||
- 0.345
|
||||
sf64_options:
|
||||
# File path of the Star Fox 64 v1.1 ROM.
|
||||
rom_path: ""
|
||||
# Folder path of where to save the patched ROM.
|
||||
patch_path: ""
|
||||
# File path of the program to automatically run.
|
||||
# Leave blank to disable.
|
||||
program_path: ""
|
||||
# Arguments to pass to the automatically run program.
|
||||
# Leave blank to disable.
|
||||
program_args: "--lua=\\\\wsl.localhost\\Ubuntu\\home\\ubufu\\ap-cm-1dd91ec\\Archipelago-main\\data\\lua\\connector_sf64_bizhawk.lua"
|
||||
# Whether to enable the built in logic Tracker.
|
||||
# If enabled, the 'Tracker' tab will show all unchecked locations in logic.
|
||||
enable_tracker: true
|
||||
sm_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "roms/Super Metroid (JU).sfc"
|
||||
sml2_options:
|
||||
# File name of the Super Mario Land 2 1.0 ROM
|
||||
rom_file: "roms/Super Mario Land 2 - 6 Golden Coins (USA, Europe).gb"
|
||||
sms_options:
|
||||
iso_file: "roms/sms_us_2002.iso"
|
||||
smw_options:
|
||||
# File name of the SMW US rom
|
||||
rom_file: "roms/Super Mario World (USA).sfc"
|
||||
soe_options:
|
||||
# File name of the SoE US ROM
|
||||
rom_file: "roms/Secret of Evermore (USA).sfc"
|
||||
spyro2_options:
|
||||
# Permits full gemsanity options for multiplayer games.
|
||||
# Full gemsanity adds 2546 locations and an equal number of progression items.
|
||||
# These items may be local-only or spread across the multiworld.
|
||||
allow_full_gemsanity: false
|
||||
stadium_options:
|
||||
# File name of the Pokemon Stadium (US, 1.0) ROM
|
||||
rom_file: "roms/Pokemon Stadium (US, 1.0).z64"
|
||||
stardew_valley_options:
|
||||
# Allow players to pick the goal 'Allsanity'. If disallowed, generation will fail.
|
||||
allow_allsanity: true
|
||||
# Allow players to pick the goal 'Perfection'. If disallowed, generation will fail.
|
||||
allow_perfection: true
|
||||
# Allow players to pick the option 'Bundle Price: Maximum'. If disallowed, it will be replaced with 'Very Expensive'
|
||||
allow_max_bundles: true
|
||||
# Allow players to pick the option 'Entrance Randomization: Chaos'. If disallowed, it will be replaced with 'Buildings'
|
||||
allow_chaos_er: false
|
||||
# Allow players to pick the option 'Shipsanity: Everything'. If disallowed, it will be replaced with 'Full Shipment With Fish'
|
||||
allow_shipsanity_everything: true
|
||||
# Allow players to pick the option 'Hatsanity: Near Perfection OR Post Perfection'. If disallowed, it will be replaced with 'Difficult'
|
||||
allow_hatsanity_perfection: true
|
||||
# Allow players to toggle on Custom logic flags. If disallowed, it will be disabled
|
||||
allow_custom_logic: true
|
||||
# Allow players to enable Jojapocalypse. If disallowed, it will be disabled
|
||||
allow_jojapocalypse: false
|
||||
tcg_card_shop_simulator_options:
|
||||
# This limits goals to a reasonable number and sets all excessive settings to local_fill or Excluded for better sync experiences.
|
||||
limit_checks_for_syncs: false
|
||||
# Card Sanity adds pure randomness to card checks. This option disables this sanity in your multiworlds
|
||||
allow_card_sanity: true
|
||||
tloz_ooa_options:
|
||||
# File path of the OOA US rom
|
||||
rom_file: "roms/Legend of Zelda, The - Oracle of Ages (USA).gbc"
|
||||
# A factor applied to the infamous heart beep sound interval.
|
||||
# Valid values are: "vanilla", "half", "quarter", "disabled"
|
||||
heart_beep_interval: "vanilla"
|
||||
# The name of the sprite file to use (from "data/sprites/oos_ooa/").
|
||||
# Putting "link" as a value uses the default game sprite.
|
||||
# Putting "random" as a value randomly picks a sprite from your sprites directory for each generated ROM.
|
||||
character_sprite: "link"
|
||||
# The color palette used for character sprite throughout the game.
|
||||
# Valid values are: "green", "red", "blue", "orange", and "random"
|
||||
character_palette: "green"
|
||||
# Defines if you don't want to spam the buttons to swim with the mermaid suit.
|
||||
qol_mermaid_suit: true
|
||||
# When enabled, playing the flute and the harp will immobilize you during a very small amount of time compared to vanilla game.
|
||||
qol_quick_flute: true
|
||||
# Defines if you want to skip the small dance that tokkay does
|
||||
skip_tokkey_dance: false
|
||||
# Defines if you want to skip the joke you tell to the sad boi
|
||||
skip_boi_joke: false
|
||||
tloz_oos_options:
|
||||
# File name of the Oracle of Seasons US ROM
|
||||
rom_file: "roms/Legend of Zelda, The - Oracle of Seasons (USA).gbc"
|
||||
# File name of the Oracle of Ages US ROM (only needed for cross items)
|
||||
ages_rom_file: "roms/Legend of Zelda, The - Oracle of Ages (USA).gbc"
|
||||
rom_start: true
|
||||
# The name of the sprite file to use (from "data/sprites/oos_ooa/").
|
||||
# Putting "link" as a value uses the default game sprite.
|
||||
# Putting "random" as a value randomly picks a sprite from your sprites directory for each generated ROM.
|
||||
# If you want some weighted result, you can arrange the options like in your option yaml.
|
||||
character_sprite: "link"
|
||||
# The color palette used for character sprite throughout the game.
|
||||
# Valid values are: "green", "red", "blue", "orange", and "random"
|
||||
# If you want some weighted result, you can arrange the options like in your option yaml.
|
||||
# If you want a color weight to only apply to a specific sprite, you can write color|sprite: weight.
|
||||
# For example, red|link: 1 would add red in the possible palettes with a weight of 1 only if link is the selected sprite
|
||||
character_palette: "green"
|
||||
# If enabled, hidden digging spots in Subrosia are revealed as diggable tiles.
|
||||
reveal_hidden_subrosia_digging_spots: true
|
||||
# A factor applied to the infamous heart beep sound interval.
|
||||
# Valid values are: "vanilla", "half", "quarter", "disabled"
|
||||
heart_beep_interval: "vanilla"
|
||||
# If true, no music will be played in the game while sound effects remain untouched
|
||||
remove_music: false
|
||||
tloz_options:
|
||||
# File name of the Zelda 1
|
||||
rom_file: "roms/Legend of Zelda, The (U) (PRG0) [!].nes"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .nes file with
|
||||
rom_start: true
|
||||
# Display message inside of Bizhawk
|
||||
display_msgs: true
|
||||
tloz_ph_options:
|
||||
# For use with universal tracker.
|
||||
# Toggles if universal tracker can use unlocked shortcuts and map warps to find shorter paths for /get_logical_path.
|
||||
ut_get_logical_path_shortcuts: true
|
||||
tloz_st_options:
|
||||
# Train speed for each of the 4 gears, from lowest (reverse) to highest.
|
||||
# defaults are -143, 0, 115, 193
|
||||
train_speed:
|
||||
- -143
|
||||
- 0
|
||||
- 115
|
||||
- 193
|
||||
# The train will instantly switch to the new speed when changing gears, no acceleration required.
|
||||
# Does not apply to your stop gear.
|
||||
train_snap_speed: true
|
||||
# Allows entering stations immediately on the stop gear, no matter your speed.
|
||||
train_quick_station: true
|
||||
ttyd_options:
|
||||
# The location of the Dolphin you want to auto launch patched ROMs with
|
||||
dolphin_path: "None"
|
||||
# File name of the TTYD US iso
|
||||
rom_file: "roms/Paper Mario - The Thousand-Year Door (USA).iso"
|
||||
rom_start: true
|
||||
tunic_options:
|
||||
# Disallows the TUNIC client from creating a local spoiler log.
|
||||
disable_local_spoiler: false
|
||||
# Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95.
|
||||
limit_grass_rando: true
|
||||
# Path to the user's TUNIC Poptracker Pack.
|
||||
ut_poptracker_path: ""
|
||||
vampire_survivors_options:
|
||||
# Allow the use of unfair characters
|
||||
allow_unfair_characters: false
|
||||
voltorb_flip_settings:
|
||||
# Allows the **experimental** choice in the **Artificial Logic** option.
|
||||
allow_experimental_logic: false
|
||||
wargroove_options:
|
||||
# Locates the Wargroove root directory on your system.
|
||||
# This is used by the Wargroove client, so it knows where to send communication files to.
|
||||
root_directory: "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||
# Locates the Wargroove save file directory on your system.
|
||||
# This is used by the Wargroove client, so it knows where to send mod and save files to.
|
||||
save_directory: "%APPDATA%"
|
||||
yoshisisland_options:
|
||||
# File name of the Yoshi's Island 1.0 US rom
|
||||
rom_file: "roms/Super Mario World 2 - Yoshi's Island (U).sfc"
|
||||
yugioh06_settings:
|
||||
# File name of your Yu-Gi-Oh 2006 ROM
|
||||
rom_file: "roms/YuGiOh06.gba"
|
||||
zillion_options:
|
||||
# File name of the Zillion US rom
|
||||
rom_file: "roms/Zillion (UE) [!].sms"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||
# You have to know the path to the emulator core library on the user's computer.
|
||||
rom_start: "retroarch"
|
||||
+3
-7
@@ -57,8 +57,9 @@ Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
||||
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
|
||||
|
||||
[Files]
|
||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs;
|
||||
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs;
|
||||
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||
|
||||
[Icons]
|
||||
@@ -82,6 +83,7 @@ Type: files; Name: "{app}\*.exe"
|
||||
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
|
||||
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
|
||||
Type: filesandordirs; Name: "{app}\SNI\lua*"
|
||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||
#include "installdelete.iss"
|
||||
|
||||
[Registry]
|
||||
@@ -206,17 +208,11 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
|
||||
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apgl"; ValueData: "{#MyAppName}glpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch"; ValueData: "Archipelago Gauntlet Legends Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}glpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
@@ -113,10 +113,6 @@ else:
|
||||
remove_between_brackets = re.compile(r"\[.*?]")
|
||||
|
||||
|
||||
def kv_unescape(text: str) -> str:
|
||||
return text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]")
|
||||
|
||||
|
||||
class ThemedApp(MDApp):
|
||||
def set_colors(self):
|
||||
text_colors = KivyJSONtoTextParser.TextColors()
|
||||
@@ -126,22 +122,6 @@ class ThemedApp(MDApp):
|
||||
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
|
||||
|
||||
|
||||
class LogtoLoadingScreen(logging.Handler):
|
||||
def __init__(self, on_log):
|
||||
super().__init__()
|
||||
self.on_log = on_log
|
||||
|
||||
def handle(self, record: logging.LogRecord):
|
||||
self.on_log(record.getMessage())
|
||||
|
||||
|
||||
class LoadingScreen(MDScreen):
|
||||
label = ObjectProperty(None)
|
||||
|
||||
def update_text(self, text):
|
||||
self.label.text = text
|
||||
|
||||
|
||||
class ImageIcon(MDButtonIcon, AsyncImage):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -370,7 +350,7 @@ class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
|
||||
ctx = self.ctx
|
||||
text = f"Connected to: {ctx.server_address}."
|
||||
if ctx.slot is not None:
|
||||
text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team + 1}, " \
|
||||
text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team}, " \
|
||||
f"named {ctx.player_names[ctx.slot]}."
|
||||
if ctx.items_received:
|
||||
text += f"\nYou have received {len(ctx.items_received)} items. " \
|
||||
@@ -383,16 +363,15 @@ class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
|
||||
text += "\nPermissions:"
|
||||
for permission_name, permission_data in ctx.permissions.items():
|
||||
text += f"\n {permission_name}: {permission_data}"
|
||||
if ctx.total_locations and ctx.hint_cost is not None:
|
||||
if ctx.hint_cost == 0:
|
||||
text += "\n!hint is free to use."
|
||||
else:
|
||||
min_cost = int(ctx.server_version >= (0, 3, 9))
|
||||
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
|
||||
f"For you this means every " \
|
||||
f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
|
||||
"location checks." \
|
||||
f"\nYou currently have {ctx.hint_points} points."
|
||||
if ctx.hint_cost is not None and ctx.total_locations:
|
||||
min_cost = int(ctx.server_version >= (0, 3, 9))
|
||||
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
|
||||
f"For you this means every " \
|
||||
f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
|
||||
"location checks." \
|
||||
f"\nYou currently have {ctx.hint_points} points."
|
||||
elif ctx.hint_cost == 0:
|
||||
text += "\n!hint is free to use."
|
||||
if ctx.stored_data and "_read_race_mode" in ctx.stored_data:
|
||||
text += "\nRace mode is enabled." \
|
||||
if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled."
|
||||
@@ -444,14 +423,14 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
|
||||
else:
|
||||
# Not a fan of the following few lines, but they work.
|
||||
temp = MarkupLabel(text=self.text).markup
|
||||
text = kv_unescape("".join(part for part in temp if not part.startswith("[")))
|
||||
text = "".join(part for part in temp if not part.startswith("["))
|
||||
cmdinput = MDApp.get_running_app().textinput
|
||||
if not cmdinput.text:
|
||||
input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command)
|
||||
if input_text is not None:
|
||||
cmdinput.text = input_text
|
||||
|
||||
Clipboard.copy(text)
|
||||
Clipboard.copy(text.replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
||||
return self.parent.select_with_touch(self.index, touch)
|
||||
|
||||
def apply_selection(self, rv, index, is_selected):
|
||||
@@ -564,7 +543,9 @@ class AutocompleteHintInput(ResizableTextField):
|
||||
item_names = ctx.item_names._game_store[ctx.game].values()
|
||||
|
||||
def on_press(text):
|
||||
self.set_text(self, text)
|
||||
split_text = MarkupLabel(text=text).markup
|
||||
self.set_text(self, "".join(text_frag for text_frag in split_text
|
||||
if not text_frag.startswith("[")))
|
||||
self.dropdown.dismiss()
|
||||
self.focus = True
|
||||
|
||||
@@ -575,13 +556,11 @@ class AutocompleteHintInput(ResizableTextField):
|
||||
except ValueError:
|
||||
pass # substring not found
|
||||
else:
|
||||
prefix = escape_markup(item_name[:index])
|
||||
matching = escape_markup(item_name[index:index+len(value)])
|
||||
postfix = escape_markup(item_name[index+len(value):])
|
||||
text = f"{prefix}[b]{matching}[/b]{postfix}"
|
||||
text = escape_markup(item_name)
|
||||
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
|
||||
self.dropdown.items.append({
|
||||
"text": text,
|
||||
"on_release": lambda txt=item_name: on_press(txt),
|
||||
"on_release": lambda txt=text: on_press(txt),
|
||||
"markup": True
|
||||
})
|
||||
if not self.dropdown.parent:
|
||||
@@ -673,7 +652,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
|
||||
else "", ". (", self.status_text.lower(), ")"))
|
||||
temp = MarkupLabel(text).markup
|
||||
text = "".join(part for part in temp if not part.startswith("["))
|
||||
Clipboard.copy(kv_unescape(text))
|
||||
Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]"))
|
||||
return self.parent.select_with_touch(self.index, touch)
|
||||
else:
|
||||
parent = self.parent
|
||||
|
||||
@@ -5,5 +5,3 @@ python_functions = test
|
||||
testpaths =
|
||||
test
|
||||
worlds
|
||||
markers =
|
||||
world: general tests that iterate over every registered world (scope with AP_TEST_WORLDS)
|
||||
+1
-1
@@ -6,7 +6,7 @@ jinja2==3.1.6
|
||||
schema==0.7.8
|
||||
kivy==2.3.1
|
||||
bsdiff4==1.2.6
|
||||
platformdirs==4.10.1
|
||||
platformdirs==4.9.4
|
||||
certifi==2026.2.25
|
||||
cython==3.2.4
|
||||
cymem==2.0.13
|
||||
|
||||
+15
-160
@@ -36,7 +36,7 @@ def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., in
|
||||
class CustomRuleRegister(type):
|
||||
"""A metaclass to contain world custom rules and automatically convert resolved rules to frozen dataclasses"""
|
||||
|
||||
resolved_rules: ClassVar[dict["Rule.Resolved", "Rule.Resolved"]] = {}
|
||||
resolved_rules: ClassVar[dict[int, "Rule.Resolved"]] = {}
|
||||
"""A cached of resolved rules to turn each unique one into a singleton"""
|
||||
|
||||
custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {}
|
||||
@@ -64,9 +64,10 @@ class CustomRuleRegister(type):
|
||||
@override
|
||||
def __call__(cls, *args: Any, **kwds: Any) -> Any:
|
||||
rule = super().__call__(*args, **kwds)
|
||||
if rule in cls.resolved_rules:
|
||||
return cls.resolved_rules[rule]
|
||||
cls.resolved_rules[rule] = rule
|
||||
rule_hash = hash(rule)
|
||||
if rule_hash in cls.resolved_rules:
|
||||
return cls.resolved_rules[rule_hash]
|
||||
cls.resolved_rules[rule_hash] = rule
|
||||
return rule
|
||||
|
||||
@classmethod
|
||||
@@ -425,142 +426,13 @@ class NestedRule(Rule[TWorld], game="Archipelago"):
|
||||
return combined_deps
|
||||
|
||||
|
||||
class AtLeast(NestedRule[TWorld], game="Archipelago"):
|
||||
"""A rule that returns true when at least N child rules evaluate as true"""
|
||||
|
||||
count: int | FieldResolver
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
count: int | FieldResolver,
|
||||
*children: Rule[TWorld],
|
||||
options: Iterable[OptionFilter] = (),
|
||||
filtered_resolution: bool = False,
|
||||
) -> None:
|
||||
super().__init__(*children, options=options, filtered_resolution=filtered_resolution)
|
||||
self.count = count
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count == 0:
|
||||
return True_().resolve(world)
|
||||
|
||||
children_to_process = [c.resolve(world) for c in self.children]
|
||||
return AtLeast.from_resolved(count, world, children_to_process)
|
||||
|
||||
@classmethod
|
||||
def from_resolved(cls, count: int, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
|
||||
clauses: list[Rule.Resolved] = []
|
||||
|
||||
while children_to_process:
|
||||
child = children_to_process.pop(0)
|
||||
if child.always_true:
|
||||
if count == 1:
|
||||
return child
|
||||
count -= 1
|
||||
continue
|
||||
if child.always_false:
|
||||
# falses can be ignored
|
||||
continue
|
||||
|
||||
clauses.append(child)
|
||||
|
||||
if len(clauses) < count:
|
||||
return False_().resolve(world)
|
||||
if count == 1:
|
||||
# Switch to Or which has more optimized handling
|
||||
return Or.from_resolved(world, clauses)
|
||||
if count == len(clauses):
|
||||
# Switch to And which has more optimized handling
|
||||
return And.from_resolved(world, clauses)
|
||||
return AtLeast.Resolved(
|
||||
tuple(clauses),
|
||||
count=count,
|
||||
player=world.player,
|
||||
caching_enabled=getattr(world, "rule_caching_enabled", False),
|
||||
)
|
||||
|
||||
@override
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
output = super().to_dict()
|
||||
count = self.count
|
||||
output["count"] = count.to_dict() if isinstance(count, FieldResolver) else count
|
||||
return output
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self:
|
||||
args = cls._parse_field_resolvers(data, world_cls.game)
|
||||
options = OptionFilter.multiple_from_dict(data.get("options", ()))
|
||||
children = [world_cls.rule_from_dict(c) for c in data.get("children", ())]
|
||||
return cls(
|
||||
args.pop("count"),
|
||||
*children,
|
||||
options=options,
|
||||
filtered_resolution=data.get("filtered_resolution", False),
|
||||
)
|
||||
|
||||
class Resolved(NestedRule.Resolved):
|
||||
count: int
|
||||
|
||||
@override
|
||||
def _evaluate(self, state: CollectionState) -> bool:
|
||||
count = self.count
|
||||
for rule in self.children:
|
||||
if rule(state):
|
||||
if count == 1:
|
||||
return True
|
||||
count -= 1
|
||||
return False
|
||||
|
||||
@override
|
||||
def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
|
||||
messages: list[JSONMessagePart] = []
|
||||
if state is None:
|
||||
messages = [
|
||||
{"type": "text", "text": "At least "},
|
||||
{"type": "color", "color": "cyan", "text": str(self.count)},
|
||||
{"type": "text", "text": " of ("},
|
||||
]
|
||||
else:
|
||||
satisfied_count = sum(1 if child(state) else 0 for child in self.children)
|
||||
messages = [
|
||||
{"type": "text", "text": "At least "},
|
||||
{"type": "color", "color": "cyan", "text": f"{satisfied_count}/{self.count}"},
|
||||
{"type": "text", "text": " of ("},
|
||||
]
|
||||
for i, child in enumerate(self.children):
|
||||
if i > 0:
|
||||
messages.append({"type": "text", "text": ", "})
|
||||
messages.extend(child.explain_json(state))
|
||||
messages.append({"type": "text", "text": ")"})
|
||||
return messages
|
||||
|
||||
@override
|
||||
def explain_str(self, state: CollectionState | None = None) -> str:
|
||||
clauses = ", ".join([c.explain_str(state) for c in self.children])
|
||||
if state is None:
|
||||
return f"At least {self.count} of ({clauses})"
|
||||
satisfied_count = sum(1 if child(state) else 0 for child in self.children)
|
||||
return f"At least {satisfied_count}/{self.count} of ({clauses})"
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
clauses = ", ".join([str(c) for c in self.children])
|
||||
return f"At least {self.count} of ({clauses})"
|
||||
|
||||
|
||||
@dataclasses.dataclass(init=False)
|
||||
class And(NestedRule[TWorld], game="Archipelago"):
|
||||
"""A rule that only returns true when all child rules evaluate as true"""
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
return And.from_resolved(world, [c.resolve(world) for c in self.children])
|
||||
|
||||
@classmethod
|
||||
def from_resolved(cls, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
|
||||
children_to_process = [c.resolve(world) for c in self.children]
|
||||
clauses: list[Rule.Resolved] = []
|
||||
items: dict[str, int] = {}
|
||||
true_rule: Rule.Resolved | None = None
|
||||
@@ -593,7 +465,7 @@ class And(NestedRule[TWorld], game="Archipelago"):
|
||||
clauses.append(child)
|
||||
|
||||
if not clauses and not items:
|
||||
return true_rule or True_().resolve(world)
|
||||
return true_rule or False_().resolve(world)
|
||||
|
||||
if len(items) == 1:
|
||||
item, count = next(iter(items.items()))
|
||||
@@ -647,10 +519,7 @@ class Or(NestedRule[TWorld], game="Archipelago"):
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
return Or.from_resolved(world, [c.resolve(world) for c in self.children])
|
||||
|
||||
@classmethod
|
||||
def from_resolved(cls, world: TWorld, children_to_process: list[Rule.Resolved]) -> Rule.Resolved:
|
||||
children_to_process = [c.resolve(world) for c in self.children]
|
||||
clauses: list[Rule.Resolved] = []
|
||||
items: dict[str, int] = {}
|
||||
|
||||
@@ -843,12 +712,9 @@ class Has(Rule[TWorld], game="Archipelago"):
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count <= 0:
|
||||
return True_().resolve(world)
|
||||
return self.Resolved(
|
||||
resolve_field(self.item_name, world, str),
|
||||
count=count,
|
||||
count=resolve_field(self.count, world, int),
|
||||
player=world.player,
|
||||
caching_enabled=getattr(world, "rule_caching_enabled", False),
|
||||
)
|
||||
@@ -1406,16 +1272,14 @@ class HasFromList(Rule[TWorld], game="Archipelago"):
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count <= 0:
|
||||
return True_().resolve(world)
|
||||
if len(self.item_names) == 0:
|
||||
# match state.has_from_list
|
||||
return False_().resolve(world)
|
||||
if len(self.item_names) == 1:
|
||||
return Has(self.item_names[0], self.count).resolve(world)
|
||||
return self.Resolved(
|
||||
self.item_names,
|
||||
count=count,
|
||||
count=resolve_field(self.count, world, int),
|
||||
player=world.player,
|
||||
caching_enabled=getattr(world, "rule_caching_enabled", False),
|
||||
)
|
||||
@@ -1543,9 +1407,8 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"):
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count <= 0:
|
||||
return True_().resolve(world)
|
||||
if len(self.item_names) < count:
|
||||
if len(self.item_names) == 0 or len(self.item_names) < count:
|
||||
# match state.has_from_list_unique
|
||||
return False_().resolve(world)
|
||||
if len(self.item_names) == 1:
|
||||
return Has(self.item_names[0]).resolve(world)
|
||||
@@ -1663,14 +1526,11 @@ class HasGroup(Rule[TWorld], game="Archipelago"):
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count <= 0:
|
||||
return True_().resolve(world)
|
||||
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
|
||||
return self.Resolved(
|
||||
self.item_name_group,
|
||||
item_names,
|
||||
count=count,
|
||||
count=resolve_field(self.count, world, int),
|
||||
player=world.player,
|
||||
caching_enabled=getattr(world, "rule_caching_enabled", False),
|
||||
)
|
||||
@@ -1740,16 +1600,11 @@ class HasGroupUnique(Rule[TWorld], game="Archipelago"):
|
||||
|
||||
@override
|
||||
def _instantiate(self, world: TWorld) -> Rule.Resolved:
|
||||
count = resolve_field(self.count, world, int)
|
||||
if count <= 0:
|
||||
return True_().resolve(world)
|
||||
item_names = tuple(sorted(world.item_name_groups[self.item_name_group]))
|
||||
if len(item_names) < count:
|
||||
return False_().resolve(world)
|
||||
return self.Resolved(
|
||||
self.item_name_group,
|
||||
item_names,
|
||||
count=count,
|
||||
count=resolve_field(self.count, world, int),
|
||||
player=world.player,
|
||||
caching_enabled=getattr(world, "rule_caching_enabled", False),
|
||||
)
|
||||
|
||||
+5
-9
@@ -98,8 +98,6 @@ class Group:
|
||||
self._changed = True
|
||||
attr = new
|
||||
# resolve the path immediately when accessing it
|
||||
if attr.exists():
|
||||
attr.__class__.validate(attr.resolve())
|
||||
return attr.__class__(attr.resolve())
|
||||
return attr
|
||||
|
||||
@@ -635,6 +633,10 @@ class ServerOptions(Group):
|
||||
class GeneratorOptions(Group):
|
||||
"""Options for Generation"""
|
||||
|
||||
class EnemizerPath(LocalFilePath):
|
||||
"""Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases"""
|
||||
is_exe = True
|
||||
|
||||
class PlayerFilesPath(OptionalUserFolderPath):
|
||||
"""Folder from which the player yaml files are pulled from"""
|
||||
# created on demand, so marked as optional
|
||||
@@ -642,12 +644,6 @@ class GeneratorOptions(Group):
|
||||
class Players(int):
|
||||
"""amount of players, 0 to infer from player files"""
|
||||
|
||||
class AllowQuantity(Bool):
|
||||
"""
|
||||
allow players to set an individual quantity for their yaml settings
|
||||
with 'false' any amounts from the players will be ignored and set to 1
|
||||
"""
|
||||
|
||||
class WeightsFilePath(str):
|
||||
"""
|
||||
general weights file, within the stated player_files_path location
|
||||
@@ -691,9 +687,9 @@ class GeneratorOptions(Group):
|
||||
start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
|
||||
"""
|
||||
|
||||
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
|
||||
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
|
||||
players: Players = Players(0)
|
||||
allow_quantity: AllowQuantity | bool = False
|
||||
weights_file_path: WeightsFilePath = WeightsFilePath("weights.yaml")
|
||||
meta_file_path: MetaFilePath = MetaFilePath("meta.yaml")
|
||||
spoiler: Spoiler = Spoiler(3)
|
||||
|
||||
@@ -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
|
||||
requirement = "cx-Freeze==8.6.4"
|
||||
requirement = 'cx-Freeze==8.4.0'
|
||||
try:
|
||||
import pkg_resources
|
||||
try:
|
||||
@@ -41,9 +41,9 @@ if install_cx_freeze:
|
||||
except ImportError:
|
||||
raise RuntimeError("pip not available. Please install pip.")
|
||||
# install and import cx_freeze
|
||||
if "--yes" not in sys.argv and "-y" not in sys.argv:
|
||||
input(f"Requirement {requirement} is not satisfied, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", requirement, "-c", "setup_constraints.txt"])
|
||||
if '--yes' not in sys.argv and '-y' not in sys.argv:
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade'])
|
||||
import pkg_resources
|
||||
|
||||
import cx_Freeze
|
||||
@@ -188,7 +188,7 @@ exes = [
|
||||
script=f"{c.script_name}.py",
|
||||
target_name=c.frozen_name + (".exe" if is_windows else ""),
|
||||
icon=resolve_icon(c.icon),
|
||||
base="gui" if is_windows and not c.cli else None
|
||||
base="Win32GUI" if is_windows and not c.cli else None
|
||||
) for c in components if c.script_name and c.frozen_name
|
||||
]
|
||||
|
||||
@@ -201,7 +201,7 @@ if is_windows:
|
||||
icon=resolve_icon(c.icon),
|
||||
))
|
||||
|
||||
extra_data = ["LICENSE", "data", "SNI"]
|
||||
extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"]
|
||||
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
||||
|
||||
|
||||
@@ -456,8 +456,9 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
for world_directory in folders_to_remove)
|
||||
else:
|
||||
# make sure extra programs are executable
|
||||
enemizer_exe = self.buildfolder / 'EnemizerCLI/EnemizerCLI.Core'
|
||||
sni_exe = self.buildfolder / 'SNI/sni'
|
||||
extra_exes = (sni_exe,)
|
||||
extra_exes = (enemizer_exe, sni_exe)
|
||||
for extra_exe in extra_exes:
|
||||
if extra_exe.is_file():
|
||||
extra_exe.chmod(0o755)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
setuptools>=75,<81
|
||||
+1
-1
@@ -202,7 +202,7 @@ class WorldTestBase(unittest.TestCase):
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
|
||||
state = self.multiworld.get_all_state()
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
reachable = location.can_reach(state)
|
||||
|
||||
@@ -88,7 +88,7 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
||||
if not locations:
|
||||
continue
|
||||
|
||||
all_state = multiworld.get_all_state()
|
||||
all_state = multiworld.get_all_state(False)
|
||||
for location in locations:
|
||||
time_taken = self.location_test(location, multiworld.state, "empty_state")
|
||||
summary_data["empty_state"][location.name] = time_taken
|
||||
|
||||
@@ -70,12 +70,10 @@ def setup_multiworld(worlds: list[type[World]] | type[World], steps: tuple[str,
|
||||
|
||||
|
||||
class TestWebWorld(WebWorld):
|
||||
__test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
|
||||
tutorials = []
|
||||
|
||||
|
||||
class TestWorld(World):
|
||||
__test__ = False # World subclass, not a test case; opt out of pytest.ini's python_classes = Test
|
||||
game = f"Test Game"
|
||||
item_name_to_id = {}
|
||||
location_name_to_id = {}
|
||||
|
||||
@@ -4,8 +4,6 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_entrance_connection_steps(self):
|
||||
"""Tests that Entrances are connected and not changed after connect_entrances."""
|
||||
def get_entrance_name_to_source_and_target_dict(world: World):
|
||||
@@ -17,7 +15,7 @@ class TestBase(unittest.TestCase):
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
additional_steps = ("generate_basic", "pre_fill")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
|
||||
@@ -44,7 +42,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, ())
|
||||
|
||||
|
||||
@@ -880,32 +880,3 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
|
||||
|
||||
self.assertRegionContains(
|
||||
self.player1.regions[2], self.player2.prog_items[0])
|
||||
|
||||
def test_does_not_displace_non_progression_skip_balancing(self) -> None:
|
||||
"""Non-progression skip-balancing items cannot be swapped into later spheres."""
|
||||
self.multiworld.worlds[self.player1.id].options.progression_balancing.value = 50
|
||||
self.multiworld.worlds[self.player2.id].options.progression_balancing.value = 50
|
||||
|
||||
protected_items = {
|
||||
location.item
|
||||
for location in self.player1.regions[1].locations
|
||||
if location.item and not location.advancement
|
||||
}
|
||||
for item in protected_items:
|
||||
item.classification |= ItemClassification.skip_balancing
|
||||
|
||||
balance_multiworld_progression(self.multiworld)
|
||||
|
||||
self.assertTrue(protected_items)
|
||||
self.assertTrue(all(item.location.parent_region is self.player1.regions[1] for item in protected_items))
|
||||
self.assertRegionContains(self.player1.regions[2], self.player2.prog_items[0])
|
||||
|
||||
def test_location_can_collapse_progression_balancing_sphere(self) -> None:
|
||||
"""Progression at a non-boundary location expands the current balancing sphere."""
|
||||
self.multiworld.worlds[self.player1.id].options.progression_balancing.value = 50
|
||||
self.multiworld.worlds[self.player2.id].options.progression_balancing.value = 50
|
||||
self.player1.prog_items[0].location.progression_balancing_sphere = False
|
||||
|
||||
balance_multiworld_progression(self.multiworld)
|
||||
|
||||
self.assertRegionContains(self.player1.regions[2], self.player2.prog_items[0])
|
||||
@@ -4,13 +4,11 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestNameGroups(TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_item_name_groups_not_empty(self) -> None:
|
||||
"""
|
||||
Test that there are no empty item name groups, which is likely a bug.
|
||||
"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.item_id_to_name:
|
||||
continue # ignore worlds without items
|
||||
with self.subTest(game=game_name):
|
||||
@@ -21,7 +19,7 @@ class TestNameGroups(TestCase):
|
||||
"""
|
||||
Test that there are no empty location name groups, which is likely a bug.
|
||||
"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.location_id_to_name:
|
||||
continue # ignore worlds without locations
|
||||
with self.subTest(game=game_name):
|
||||
|
||||
@@ -7,25 +7,23 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestIDs(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_range_items(self):
|
||||
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
for item_id in world_type.item_id_to_name:
|
||||
self.assertLess(item_id, 2**53)
|
||||
|
||||
def test_range_locations(self):
|
||||
"""There are Javascript clients, which are limited to Number.MAX_SAFE_INTEGER due to 64bit float precision."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
for location_id in world_type.location_id_to_name:
|
||||
self.assertLess(location_id, 2**53)
|
||||
|
||||
def test_reserved_items(self):
|
||||
"""negative item IDs are reserved to the special "Archipelago" world."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
if gamename == "Archipelago":
|
||||
for item_id in world_type.item_id_to_name:
|
||||
@@ -36,7 +34,7 @@ class TestIDs(unittest.TestCase):
|
||||
|
||||
def test_reserved_locations(self):
|
||||
"""negative location IDs are reserved to the special "Archipelago" world."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
if gamename == "Archipelago":
|
||||
for location_id in world_type.location_id_to_name:
|
||||
@@ -47,7 +45,7 @@ class TestIDs(unittest.TestCase):
|
||||
|
||||
def test_duplicate_item_ids(self):
|
||||
"""Test that a game doesn't have item id overlap within its own datapackage"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
len_item_id_to_name = len(world_type.item_id_to_name)
|
||||
len_item_name_to_id = len(world_type.item_name_to_id)
|
||||
@@ -66,7 +64,7 @@ class TestIDs(unittest.TestCase):
|
||||
|
||||
def test_duplicate_location_ids(self):
|
||||
"""Test that a game doesn't have location id overlap within its own datapackage"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
len_location_id_to_name = len(world_type.location_id_to_name)
|
||||
len_location_name_to_id = len(world_type.location_name_to_id)
|
||||
@@ -85,7 +83,7 @@ class TestIDs(unittest.TestCase):
|
||||
|
||||
def test_postgen_datapackage(self):
|
||||
"""Generates a solo multiworld and checks that the datapackage is still valid"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
distribute_items_restrictive(multiworld)
|
||||
|
||||
@@ -8,11 +8,9 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestImplemented(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_completion_condition(self):
|
||||
"""Ensure a completion condition is set that has requirements."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
@@ -20,7 +18,7 @@ class TestImplemented(unittest.TestCase):
|
||||
|
||||
def test_entrance_parents(self):
|
||||
"""Tests that the parents of created Entrances match the exiting Region."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
@@ -30,7 +28,7 @@ class TestImplemented(unittest.TestCase):
|
||||
|
||||
def test_stage_methods(self):
|
||||
"""Tests that worlds don't try to implement certain steps that are only ever called as stage."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
for method in ("assert_generate",):
|
||||
@@ -42,7 +40,7 @@ class TestImplemented(unittest.TestCase):
|
||||
# has an await for generate_output which isn't being called
|
||||
excluded_games = ("Ocarina of Time",)
|
||||
worlds_to_test = {game: world
|
||||
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
|
||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||
for game_name, world_type in worlds_to_test.items():
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||
@@ -56,16 +54,16 @@ class TestImplemented(unittest.TestCase):
|
||||
|
||||
def test_no_failed_world_loads(self):
|
||||
if failed_world_loads:
|
||||
self.fail(f"The following worlds failed to load: {failed_world_loads.keys()}")
|
||||
self.fail(f"The following worlds failed to load: {failed_world_loads}")
|
||||
|
||||
def test_prefill_items(self):
|
||||
"""Test that every world can reach every location from allstate before pre_fill."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
|
||||
with self.subTest(gamename):
|
||||
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
|
||||
"set_rules", "connect_entrances", "generate_basic"))
|
||||
allstate = multiworld.get_all_state()
|
||||
allstate = multiworld.get_all_state(False)
|
||||
locations = multiworld.get_locations()
|
||||
reachable = multiworld.get_reachable_locations(allstate)
|
||||
unreachable = [location for location in locations if location not in reachable]
|
||||
@@ -80,7 +78,7 @@ class TestImplemented(unittest.TestCase):
|
||||
# Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is
|
||||
# nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect
|
||||
# conditions.
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
world = multiworld.get_game_worlds(game_name)[0]
|
||||
if not world.explicit_indirect_conditions:
|
||||
@@ -142,7 +140,7 @@ class TestImplemented(unittest.TestCase):
|
||||
|
||||
def test_no_items_or_locations_or_regions_submitted_in_init(self):
|
||||
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, ())
|
||||
self.assertEqual(len(multiworld.itempool), 0)
|
||||
|
||||
@@ -11,11 +11,9 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_create_item(self):
|
||||
"""Test that a world can successfully create all items in its datapackage"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
|
||||
proxy_world = multiworld.worlds[1]
|
||||
for item_name in world_type.item_name_to_id:
|
||||
@@ -55,7 +53,7 @@ class TestBase(unittest.TestCase):
|
||||
"Yu-Gi-Oh! 2006":
|
||||
{"Campaign Boss Beaten"}
|
||||
}
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game_name, game_name=game_name):
|
||||
exclusions = exclusion_dict.get(game_name, frozenset())
|
||||
for group_name, items in world_type.item_name_groups.items():
|
||||
@@ -66,7 +64,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_item_name_group_conflict(self):
|
||||
"""Test that all item name groups aren't also item names."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game_name, game_name=game_name):
|
||||
for group_name in world_type.item_name_groups:
|
||||
with self.subTest(group_name, group_name=group_name):
|
||||
@@ -74,7 +72,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_item_count_equal_locations(self):
|
||||
"""Test that by the pre_fill step under default settings, each game submits items == locations"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
self.assertEqual(
|
||||
@@ -86,7 +84,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_items_in_datapackage(self):
|
||||
"""Test that any created items in the itempool are in the datapackage"""
|
||||
archipelago = AutoWorldRegister.world_types["Archipelago"]
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
for item in multiworld.itempool:
|
||||
@@ -128,7 +126,7 @@ class TestBase(unittest.TestCase):
|
||||
call_all(multiworld, "finalize_multiworld")
|
||||
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
|
||||
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Can generate with link replacement", game=game_name):
|
||||
setup_link_multiworld(world_type, True)
|
||||
with self.subTest("Can generate without link replacement", game=game_name):
|
||||
@@ -138,9 +136,9 @@ class TestBase(unittest.TestCase):
|
||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
excluded_games = ("Ocarina of Time", "SMZ3")
|
||||
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
|
||||
worlds_to_test = {game: world
|
||||
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
|
||||
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=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
@@ -155,7 +153,7 @@ class TestBase(unittest.TestCase):
|
||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||
gen_steps = ("generate_early",)
|
||||
additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
local_items = multiworld.worlds[1].options.local_items.value.copy()
|
||||
|
||||
@@ -5,11 +5,9 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_create_duplicate_locations(self):
|
||||
"""Tests that no two Locations share a name or ID."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
locations = Counter(location.name for location in multiworld.get_locations())
|
||||
if locations:
|
||||
@@ -24,7 +22,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_locations_in_datapackage(self):
|
||||
"""Tests that created locations not filled before fill starts exist in the datapackage."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game_name=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
locations = multiworld.get_unfilled_locations() # do unfilled locations to avoid Events
|
||||
@@ -37,7 +35,7 @@ class TestBase(unittest.TestCase):
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
excluded_games = ("Ocarina of Time", "Pokemon Red and Blue")
|
||||
worlds_to_test = {game: world
|
||||
for game, world in AutoWorldRegister.testable_worlds.items() if game not in excluded_games}
|
||||
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):
|
||||
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||
@@ -70,7 +68,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_location_group(self):
|
||||
"""Test that all location name groups contain valid locations and don't share names."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game_name, game_name=game_name):
|
||||
for group_name, locations in world_type.location_name_groups.items():
|
||||
with self.subTest(group_name, group_name=group_name):
|
||||
|
||||
@@ -6,14 +6,12 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestWorldMemory(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_leak(self) -> None:
|
||||
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
||||
import gc
|
||||
import weakref
|
||||
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game creation", game_name=game_name):
|
||||
weak = weakref.ref(setup_solo_multiworld(world_type))
|
||||
refs[game_name] = weak
|
||||
|
||||
@@ -3,11 +3,9 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_item_names_format(self) -> None:
|
||||
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
for item_name in world_type.item_name_to_id:
|
||||
self.assertFalse(item_name.isnumeric(),
|
||||
@@ -15,7 +13,7 @@ class TestNames(unittest.TestCase):
|
||||
|
||||
def test_location_name_format(self) -> None:
|
||||
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
for location_name in world_type.location_name_to_id:
|
||||
self.assertFalse(location_name.isnumeric(),
|
||||
|
||||
@@ -8,11 +8,9 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestOptions(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_options_have_doc_string(self):
|
||||
"""Test that submitted options have their own specified docstring"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
with self.subTest(game=gamename, option=option_key):
|
||||
@@ -20,7 +18,7 @@ class TestOptions(unittest.TestCase):
|
||||
|
||||
def test_option_defaults(self):
|
||||
"""Test that defaults for submitted options are valid."""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
with self.subTest(game=gamename, option=option_key):
|
||||
@@ -43,14 +41,14 @@ class TestOptions(unittest.TestCase):
|
||||
|
||||
def test_options_are_not_set_by_world(self):
|
||||
"""Test that options attribute is not already set"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
self.assertFalse(hasattr(world_type, "options"),
|
||||
f"Unexpected assignment to {world_type.__name__}.options!")
|
||||
|
||||
def test_duplicate_options(self) -> None:
|
||||
"""Tests that a world doesn't reuse the same option class."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=game_name):
|
||||
seen_options = set()
|
||||
for option in world_type.options_dataclass.type_hints.values():
|
||||
@@ -100,7 +98,7 @@ class TestOptions(unittest.TestCase):
|
||||
|
||||
def test_pickle_dumps_default(self):
|
||||
"""Test that default option values can be pickled into database for WebHost generation"""
|
||||
for gamename, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
with self.subTest(game=gamename, option=option_key):
|
||||
@@ -110,7 +108,7 @@ class TestOptions(unittest.TestCase):
|
||||
|
||||
def test_option_set_keys_random(self):
|
||||
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if game_name not in ("Archipelago", "Super Metroid"):
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
if issubclass(option, OptionSet):
|
||||
|
||||
@@ -3,8 +3,6 @@ import os
|
||||
|
||||
|
||||
class TestPackages(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_packages_have_init(self):
|
||||
"""Test that all world folders containing .py files also have a __init__.py file,
|
||||
to indicate full package rather than namespace package."""
|
||||
|
||||
@@ -4,8 +4,6 @@ from worlds.Files import AutoPatchRegister
|
||||
|
||||
|
||||
class TestPatches(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_patch_name_matches_game(self) -> None:
|
||||
for game_name in AutoPatchRegister.patch_types:
|
||||
with self.subTest(game=game_name):
|
||||
|
||||
@@ -6,7 +6,6 @@ from . import setup_solo_multiworld, gen_steps
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
gen_steps = gen_steps
|
||||
|
||||
default_settings_unreachable_regions = {
|
||||
@@ -46,11 +45,11 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_default_all_state_can_reach_everything(self):
|
||||
"""Ensure all state can reach everything and complete the game with the defined options"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set())
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
state = multiworld.get_all_state()
|
||||
state = multiworld.get_all_state(False)
|
||||
for location in multiworld.get_locations():
|
||||
with self.subTest("Location should be reached", location=location.name):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
@@ -68,7 +67,7 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_default_empty_state_can_reach_something(self):
|
||||
"""Ensure empty state can reach at least one location with the defined options"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
state = CollectionState(multiworld)
|
||||
|
||||
@@ -3,8 +3,6 @@ import os
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_requirements_file_ends_on_newline(self):
|
||||
"""Test that all requirements files end on a newline"""
|
||||
import Utils
|
||||
|
||||
@@ -12,7 +12,6 @@ from rule_builder.field_resolvers import FieldResolver, FromOption, FromWorldAtt
|
||||
from rule_builder.options import Operator, OptionFilter
|
||||
from rule_builder.rules import (
|
||||
And,
|
||||
AtLeast,
|
||||
CanReachEntrance,
|
||||
CanReachLocation,
|
||||
CanReachRegion,
|
||||
@@ -159,18 +158,6 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
|
||||
|
||||
@classvar_matrix(
|
||||
rules=(
|
||||
(
|
||||
And(),
|
||||
True_.Resolved(player=1)
|
||||
),
|
||||
(
|
||||
Or(),
|
||||
False_.Resolved(player=1)
|
||||
),
|
||||
(
|
||||
Has("A", 0),
|
||||
True_.Resolved(player=1)
|
||||
),
|
||||
(
|
||||
And(Has("A", 1), Has("A", 2)),
|
||||
Has.Resolved("A", 2, player=1),
|
||||
@@ -263,40 +250,6 @@ class CachedRuleBuilderTestCase(RuleBuilderTestCase):
|
||||
Or(HasAnyCount({"A": 1, "B": 2}), HasAnyCount({"A": 2, "B": 2})),
|
||||
HasAnyCount.Resolved((("A", 1), ("B", 2)), player=1),
|
||||
),
|
||||
(
|
||||
AtLeast(0, Has("A")),
|
||||
True_.Resolved(player=1),
|
||||
),
|
||||
(
|
||||
AtLeast(3, True_(), Has("A"), Has("B"), Has("C")),
|
||||
AtLeast.Resolved(
|
||||
(Has.Resolved("A", player=1), Has.Resolved("B", player=1), Has.Resolved("C", player=1)), 2, player=1
|
||||
),
|
||||
),
|
||||
(
|
||||
AtLeast(2, False_(), Has("A"), Has("B"), Has("C")),
|
||||
AtLeast.Resolved(
|
||||
(Has.Resolved("A", player=1), Has.Resolved("B", player=1), Has.Resolved("C", player=1)), 2, player=1
|
||||
),
|
||||
),
|
||||
(
|
||||
AtLeast(2, True_(), True_(), Has("A")),
|
||||
True_.Resolved(player=1),
|
||||
),
|
||||
(
|
||||
AtLeast(3, Has("A"), Has("B")),
|
||||
False_.Resolved(player=1),
|
||||
),
|
||||
(
|
||||
# This test will fail when Or(Rule, Rule) will be optimized to Rule
|
||||
AtLeast(1, Rule(), Rule()),
|
||||
Or.Resolved((Rule.Resolved(player=1), Rule.Resolved(player=1)), player=1),
|
||||
),
|
||||
(
|
||||
# This test will fail when And(Rule, Rule) will be optimized to Rule
|
||||
AtLeast(2, Rule(), Rule()),
|
||||
And.Resolved((Rule.Resolved(player=1), Rule.Resolved(player=1)), player=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
class TestSimplify(RuleBuilderTestCase):
|
||||
@@ -463,15 +416,6 @@ class TestHashes(RuleBuilderTestCase):
|
||||
rule2 = HasAll("2", "2", "2", "1")
|
||||
self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world)))
|
||||
|
||||
def test_hash_collision(self) -> None:
|
||||
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
|
||||
world = multiworld.worlds[1]
|
||||
rule1 = Has("A", count=1).resolve(world)
|
||||
rule2 = Has("A", count=1 << 61).resolve(world)
|
||||
self.assertEqual(hash(rule1), hash(rule2))
|
||||
self.assertNotEqual(rule1, rule2)
|
||||
self.assertNotEqual(id(rule1), id(rule2))
|
||||
|
||||
|
||||
class TestCaching(CachedRuleBuilderTestCase):
|
||||
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
@@ -678,24 +622,6 @@ class TestRules(RuleBuilderTestCase):
|
||||
self.state.remove(item)
|
||||
self.assertFalse(resolved_rule(self.state))
|
||||
|
||||
def test_at_least(self) -> None:
|
||||
# Has has to be relied on as True_ and False_ would be optimized out
|
||||
rule = AtLeast(2, Has("Item 1"), Has("Item 1"), Has("Item 2"), Has("Item 3"))
|
||||
resolved_rule = rule.resolve(self.world)
|
||||
self.world.register_rule_dependencies(resolved_rule)
|
||||
item1 = self.world.create_item("Item 1")
|
||||
item2 = self.world.create_item("Item 2")
|
||||
item3 = self.world.create_item("Item 3")
|
||||
self.assertFalse(resolved_rule(self.state))
|
||||
self.state.collect(item1)
|
||||
self.assertTrue(resolved_rule(self.state))
|
||||
self.state.collect(item2)
|
||||
self.assertTrue(resolved_rule(self.state))
|
||||
self.state.remove(item1)
|
||||
self.assertFalse(resolved_rule(self.state))
|
||||
self.state.collect(item3)
|
||||
self.assertTrue(resolved_rule(self.state))
|
||||
|
||||
def test_has_all(self) -> None:
|
||||
rule = HasAll("Item 1", "Item 2")
|
||||
resolved_rule = rule.resolve(self.world)
|
||||
@@ -871,13 +797,8 @@ class TestSerialization(RuleBuilderTestCase):
|
||||
OptionFilter(ChoiceOption, ChoiceOption.option_second, "ge"),
|
||||
],
|
||||
),
|
||||
AtLeast(
|
||||
FromWorldAttr("instance_data.at_least_requirement"),
|
||||
Has("i15", count=2),
|
||||
HasGroup("g2", count=3),
|
||||
),
|
||||
CanReachEntrance("e1"),
|
||||
HasGroupUnique("g3", count=5),
|
||||
HasGroupUnique("g2", count=5),
|
||||
)
|
||||
|
||||
rule_dict: ClassVar[dict[str, Any]] = {
|
||||
@@ -1001,29 +922,6 @@ class TestSerialization(RuleBuilderTestCase):
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"rule": "AtLeast",
|
||||
"options": [],
|
||||
"filtered_resolution": False,
|
||||
"count": {"resolver": "FromWorldAttr", "name": "instance_data.at_least_requirement"},
|
||||
"children": [
|
||||
{
|
||||
"rule": "Has",
|
||||
"options": [],
|
||||
"filtered_resolution": False,
|
||||
"args": {
|
||||
"item_name": "i15",
|
||||
"count": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
"rule": "HasGroup",
|
||||
"options": [],
|
||||
"filtered_resolution": False,
|
||||
"args": {"item_name_group": "g2", "count": 3},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"rule": "CanReachEntrance",
|
||||
"options": [],
|
||||
@@ -1034,7 +932,7 @@ class TestSerialization(RuleBuilderTestCase):
|
||||
"rule": "HasGroupUnique",
|
||||
"options": [],
|
||||
"filtered_resolution": False,
|
||||
"args": {"item_name_group": "g3", "count": 5},
|
||||
"args": {"item_name_group": "g2", "count": 5},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1066,15 +964,9 @@ class TestExplain(RuleBuilderTestCase):
|
||||
),
|
||||
player=1,
|
||||
),
|
||||
AtLeast.Resolved(
|
||||
children=(
|
||||
HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1),
|
||||
HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1),
|
||||
HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1),
|
||||
),
|
||||
count=2,
|
||||
player=1,
|
||||
),
|
||||
HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1),
|
||||
HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1),
|
||||
HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1),
|
||||
HasFromListUnique.Resolved(("Item 13", "Item 14"), player=1),
|
||||
HasGroup.Resolved("Group 1", ("Item 15", "Item 16", "Item 17"), player=1),
|
||||
HasGroupUnique.Resolved("Group 2", ("Item 18", "Item 19"), count=2, player=1),
|
||||
@@ -1139,9 +1031,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "At least "},
|
||||
{"type": "color", "color": "cyan", "text": "0/2"},
|
||||
{"type": "text", "text": " of ("},
|
||||
{"type": "text", "text": "Missing "},
|
||||
{"type": "color", "color": "cyan", "text": "some"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1152,7 +1041,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "salmon", "text": "Item 7"},
|
||||
{"type": "text", "text": " x5"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Missing "},
|
||||
{"type": "color", "color": "cyan", "text": "all"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1163,7 +1052,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "salmon", "text": "Item 9"},
|
||||
{"type": "text", "text": " x3"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "salmon", "text": "0/2"},
|
||||
{"type": "text", "text": " items from ("},
|
||||
@@ -1174,7 +1063,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "color", "color": "salmon", "text": "Item 12"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "salmon", "text": "0/1"},
|
||||
@@ -1241,9 +1129,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "At least "},
|
||||
{"type": "color", "color": "cyan", "text": "3/2"},
|
||||
{"type": "text", "text": " of ("},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "all"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1254,7 +1139,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "green", "text": "Item 7"},
|
||||
{"type": "text", "text": " x5"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "some"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1265,7 +1150,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "green", "text": "Item 9"},
|
||||
{"type": "text", "text": " x3"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "green", "text": "30/2"},
|
||||
{"type": "text", "text": " items from ("},
|
||||
@@ -1276,7 +1161,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "color", "color": "green", "text": "Item 12"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "green", "text": "2/1"},
|
||||
@@ -1311,7 +1195,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "salmon", "text": "False"},
|
||||
{"type": "text", "text": ")"},
|
||||
]
|
||||
self.assertEqual(self.resolved_rule.explain_json(self.state), expected)
|
||||
assert self.resolved_rule.explain_json(self.state) == expected
|
||||
|
||||
def test_explain_json_without_state(self) -> None:
|
||||
expected: list[JSONMessagePart] = [
|
||||
@@ -1339,9 +1223,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "At least "},
|
||||
{"type": "color", "color": "cyan", "text": "2"},
|
||||
{"type": "text", "text": " of ("},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "all"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1351,7 +1232,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "item_name", "flags": 1, "text": "Item 7", "player": 1},
|
||||
{"type": "text", "text": " x5"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "any"},
|
||||
{"type": "text", "text": " of ("},
|
||||
@@ -1361,7 +1242,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "item_name", "flags": 1, "text": "Item 9", "player": 1},
|
||||
{"type": "text", "text": " x3"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "2"},
|
||||
{"type": "text", "text": "x items from ("},
|
||||
@@ -1371,7 +1252,6 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "text", "text": ", "},
|
||||
{"type": "item_name", "flags": 1, "text": "Item 12", "player": 1},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": ")"},
|
||||
{"type": "text", "text": " & "},
|
||||
{"type": "text", "text": "Has "},
|
||||
{"type": "color", "color": "cyan", "text": "1"},
|
||||
@@ -1405,16 +1285,16 @@ class TestExplain(RuleBuilderTestCase):
|
||||
{"type": "color", "color": "salmon", "text": "False"},
|
||||
{"type": "text", "text": ")"},
|
||||
]
|
||||
self.assertEqual(self.resolved_rule.explain_json(), expected)
|
||||
assert self.resolved_rule.explain_json() == expected
|
||||
|
||||
def test_explain_str_with_state_no_items(self) -> None:
|
||||
expected = (
|
||||
"((Missing 4x Item 1",
|
||||
"| Missing some of (Missing: Item 2, Item 3)",
|
||||
"| Missing all of (Missing: Item 4, Item 5))",
|
||||
"& At least 0/2 of (Missing some of (Missing: Item 6 x1, Item 7 x5),",
|
||||
"Missing all of (Missing: Item 8 x2, Item 9 x3),",
|
||||
"Has 0/2 items from (Missing: Item 10, Item 11, Item 12))",
|
||||
"& Missing some of (Missing: Item 6 x1, Item 7 x5)",
|
||||
"& Missing all of (Missing: Item 8 x2, Item 9 x3)",
|
||||
"& Has 0/2 items from (Missing: Item 10, Item 11, Item 12)",
|
||||
"& Has 0/1 unique items from (Missing: Item 13, Item 14)",
|
||||
"& Has 0/1 items from Group 1",
|
||||
"& Has 0/2 unique items from Group 2",
|
||||
@@ -1424,7 +1304,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
"& True",
|
||||
"& False)",
|
||||
)
|
||||
self.assertEqual(self.resolved_rule.explain_str(self.state), " ".join(expected))
|
||||
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
|
||||
|
||||
def test_explain_str_with_state_all_items(self) -> None:
|
||||
self._collect_all()
|
||||
@@ -1433,9 +1313,9 @@ class TestExplain(RuleBuilderTestCase):
|
||||
"((Has 4x Item 1",
|
||||
"| Has all of (Found: Item 2, Item 3)",
|
||||
"| Has some of (Found: Item 4, Item 5))",
|
||||
"& At least 3/2 of (Has all of (Found: Item 6 x1, Item 7 x5),",
|
||||
"Has some of (Found: Item 8 x2, Item 9 x3),",
|
||||
"Has 30/2 items from (Found: Item 10, Item 11, Item 12))",
|
||||
"& Has all of (Found: Item 6 x1, Item 7 x5)",
|
||||
"& Has some of (Found: Item 8 x2, Item 9 x3)",
|
||||
"& Has 30/2 items from (Found: Item 10, Item 11, Item 12)",
|
||||
"& Has 2/1 unique items from (Found: Item 13, Item 14)",
|
||||
"& Has 30/1 items from Group 1",
|
||||
"& Has 2/2 unique items from Group 2",
|
||||
@@ -1445,16 +1325,16 @@ class TestExplain(RuleBuilderTestCase):
|
||||
"& True",
|
||||
"& False)",
|
||||
)
|
||||
self.assertEqual(self.resolved_rule.explain_str(self.state), " ".join(expected))
|
||||
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
|
||||
|
||||
def test_explain_str_without_state(self) -> None:
|
||||
expected = (
|
||||
"((Has 4x Item 1",
|
||||
"| Has all of (Item 2, Item 3)",
|
||||
"| Has any of (Item 4, Item 5))",
|
||||
"& At least 2 of (Has all of (Item 6 x1, Item 7 x5),",
|
||||
"Has any of (Item 8 x2, Item 9 x3),",
|
||||
"Has 2x items from (Item 10, Item 11, Item 12))",
|
||||
"& Has all of (Item 6 x1, Item 7 x5)",
|
||||
"& Has any of (Item 8 x2, Item 9 x3)",
|
||||
"& Has 2x items from (Item 10, Item 11, Item 12)",
|
||||
"& Has a unique item from (Item 13, Item 14)",
|
||||
"& Has an item from Group 1",
|
||||
"& Has 2x unique items from Group 2",
|
||||
@@ -1464,16 +1344,16 @@ class TestExplain(RuleBuilderTestCase):
|
||||
"& True",
|
||||
"& False)",
|
||||
)
|
||||
self.assertEqual(self.resolved_rule.explain_str(), " ".join(expected))
|
||||
assert self.resolved_rule.explain_str() == " ".join(expected)
|
||||
|
||||
def test_str(self) -> None:
|
||||
expected = (
|
||||
"((Has 4x Item 1",
|
||||
"| Has all of (Item 2, Item 3)",
|
||||
"| Has any of (Item 4, Item 5))",
|
||||
"& At least 2 of (Has all of (Item 6 x1, Item 7 x5),",
|
||||
"Has any of (Item 8 x2, Item 9 x3),",
|
||||
"Has 2x items from (Item 10, Item 11, Item 12))",
|
||||
"& Has all of (Item 6 x1, Item 7 x5)",
|
||||
"& Has any of (Item 8 x2, Item 9 x3)",
|
||||
"& Has 2x items from (Item 10, Item 11, Item 12)",
|
||||
"& Has a unique item from (Item 13, Item 14)",
|
||||
"& Has an item from Group 1",
|
||||
"& Has 2x unique items from Group 2",
|
||||
@@ -1483,7 +1363,7 @@ class TestExplain(RuleBuilderTestCase):
|
||||
"& True",
|
||||
"& False)",
|
||||
)
|
||||
self.assertEqual(str(self.resolved_rule), " ".join(expected))
|
||||
assert str(self.resolved_rule) == " ".join(expected)
|
||||
|
||||
|
||||
@classvar_matrix(
|
||||
|
||||
@@ -5,13 +5,11 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestSettings(TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_settings_can_update(self) -> None:
|
||||
"""
|
||||
Test that world settings can update.
|
||||
"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=game_name):
|
||||
if world_type.settings is not None:
|
||||
assert isinstance(world_type.settings, Group)
|
||||
|
||||
@@ -5,7 +5,6 @@ from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
world_relevant = True
|
||||
gen_steps = (
|
||||
"generate_early",
|
||||
"create_regions",
|
||||
@@ -21,10 +20,10 @@ class TestBase(unittest.TestCase):
|
||||
|
||||
def test_all_state_is_available(self):
|
||||
"""Ensure all_state can be created at certain steps."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
|
||||
for step in self.test_steps:
|
||||
with self.subTest("Step", step=step):
|
||||
call_all(multiworld, step)
|
||||
self.assertTrue(multiworld.get_all_state(allow_partial_entrances=True))
|
||||
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))
|
||||
@@ -22,7 +22,7 @@ worlds_paths = [
|
||||
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
|
||||
source_world_names = [
|
||||
k
|
||||
for k, v in AutoWorldRegister.testable_worlds.items()
|
||||
for k, v in AutoWorldRegister.world_types.items()
|
||||
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
|
||||
]
|
||||
|
||||
@@ -43,7 +43,6 @@ def get_source_world_manifest_path(game: str) -> Path | None:
|
||||
# TODO: remove the filter once manifests are mandatory.
|
||||
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
|
||||
class TestWorldManifest(unittest.TestCase):
|
||||
world_relevant = True
|
||||
game: ClassVar[str]
|
||||
manifest: ClassVar[dict[str, Any]]
|
||||
|
||||
|
||||
@@ -65,14 +65,13 @@ class TestAllGamesMultiworld(MultiworldTestBase):
|
||||
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
|
||||
|
||||
|
||||
@classvar_matrix(game=AutoWorldRegister.testable_worlds.keys())
|
||||
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
|
||||
class TestTwoPlayerMulti(MultiworldTestBase):
|
||||
world_relevant = True
|
||||
game: ClassVar[str]
|
||||
|
||||
def test_two_player_single_game_fills(self) -> None:
|
||||
"""Tests that a multiworld of two players for each registered game world can generate."""
|
||||
world_type = AutoWorldRegister.testable_worlds[self.game]
|
||||
world_type = AutoWorldRegister.world_types[self.game]
|
||||
self.multiworld = setup_multiworld([world_type, world_type], ())
|
||||
for world in self.multiworld.worlds.values():
|
||||
world.options.accessibility.value = Accessibility.option_full
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
from NetUtils import decode, encode
|
||||
|
||||
|
||||
class DecodeDepthLimitTest(unittest.TestCase):
|
||||
LIMIT = 16
|
||||
|
||||
@staticmethod
|
||||
def make_data(depth: int = LIMIT, cmd: str = "Cmd") -> list[dict[str, Any]]:
|
||||
arg: Any = [1]
|
||||
for _ in range(depth - 4):
|
||||
arg = [arg]
|
||||
res = {"cmd": cmd, "arg": arg}
|
||||
# [{"arg": [[...[1]...]]}]
|
||||
# ^1 ^depth
|
||||
return [res]
|
||||
|
||||
@classmethod
|
||||
def make_message(cls, depth: int = LIMIT, cmd: str = "Cmd") -> str:
|
||||
return encode(cls.make_data(depth, cmd=cmd))
|
||||
|
||||
def test_below_limit(self) -> None:
|
||||
data = self.make_data(depth=self.LIMIT - 1)
|
||||
message = encode(data)
|
||||
self.assertEqual(data, decode(message))
|
||||
|
||||
def test_at_limit(self) -> None:
|
||||
data = self.make_data(depth=self.LIMIT)
|
||||
message = encode(data)
|
||||
self.assertEqual(data, decode(message))
|
||||
|
||||
def test_above_limit(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode(self.make_message(depth=self.LIMIT + 1))
|
||||
|
||||
def test_incomplete(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode(self.make_message()[:-1])
|
||||
|
||||
def test_invalid(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode(self.make_message().replace(":", ","))
|
||||
|
||||
def test_braces_in_str(self) -> None:
|
||||
# should not raise
|
||||
decode(self.make_message(cmd="["))
|
||||
decode(self.make_message(cmd="{"))
|
||||
decode(self.make_message(cmd="}"))
|
||||
decode(self.make_message(cmd="]"))
|
||||
|
||||
def test_quote_in_str(self) -> None:
|
||||
# should not raise
|
||||
decode(self.make_message(cmd='"'))
|
||||
|
||||
def test_bs_quote_in_str(self) -> None:
|
||||
# should not raise
|
||||
decode(self.make_message(cmd=r'\"'))
|
||||
|
||||
def test_quoted_braces_in_str(self) -> None:
|
||||
# should not raise
|
||||
decode(self.make_message(cmd='"{["'))
|
||||
|
||||
def test_escape(self) -> None:
|
||||
# should not raise
|
||||
decode(r"""["\"\\\/\b\f\n\r\t{"]""")
|
||||
self.assertEqual("new\nline", decode(r'"new\u000Aline"'))
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Verify that NetUtils' enums work correctly with all supported Python versions."""
|
||||
|
||||
import pickle
|
||||
import unittest
|
||||
from enum import Enum
|
||||
from typing import Type
|
||||
|
||||
from NetUtils import ClientStatus, HintStatus, SlotType
|
||||
from Utils import restricted_loads
|
||||
|
||||
|
||||
class Base:
|
||||
class DataEnumTest(unittest.TestCase):
|
||||
type: Type[Enum]
|
||||
value: Enum
|
||||
|
||||
def test_unpickle(self) -> None:
|
||||
"""Tests that enums used in multidata or multisave can be pickled and unpickled."""
|
||||
pickled = pickle.dumps(self.value)
|
||||
unpickled = restricted_loads(pickled)
|
||||
self.assertEqual(unpickled, self.value)
|
||||
self.assertIsInstance(unpickled, self.type)
|
||||
|
||||
|
||||
class HintStatusTest(Base.DataEnumTest):
|
||||
type = HintStatus
|
||||
value = HintStatus.HINT_AVOID
|
||||
|
||||
|
||||
class ClientStatusTest(Base.DataEnumTest):
|
||||
type = ClientStatus
|
||||
value = ClientStatus.CLIENT_GOAL
|
||||
|
||||
|
||||
class SlotTypeTest(Base.DataEnumTest):
|
||||
type = SlotType
|
||||
value = SlotType.player
|
||||
@@ -1,8 +1,6 @@
|
||||
import unittest
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from Options import Choice, DefaultOnToggle, Toggle, OptionDict, OptionError, OptionSet, OptionList, OptionCounter
|
||||
from Options import Choice, DefaultOnToggle, Toggle
|
||||
|
||||
|
||||
class TestNumericOptions(unittest.TestCase):
|
||||
@@ -76,97 +74,3 @@ class TestNumericOptions(unittest.TestCase):
|
||||
self.assertTrue(toggle_string)
|
||||
self.assertTrue(toggle_int)
|
||||
self.assertTrue(toggle_alias)
|
||||
|
||||
|
||||
class TestContainerOptions(unittest.TestCase):
|
||||
def test_option_dict(self):
|
||||
class TestOptionDict(OptionDict):
|
||||
valid_keys = frozenset({"A", "B", "C"})
|
||||
|
||||
unknown_key_init_dict = {"D": "Foo"}
|
||||
test_option_dict = TestOptionDict(unknown_key_init_dict)
|
||||
self.assertRaises(OptionError, test_option_dict.verify_keys)
|
||||
|
||||
init_dict = {"A": "foo", "B": "bar"}
|
||||
test_option_dict = TestOptionDict(init_dict)
|
||||
|
||||
self.assertEqual(test_option_dict, init_dict) # Implicit value comparison
|
||||
self.assertEqual(test_option_dict["A"], "foo")
|
||||
self.assertIn("B", test_option_dict)
|
||||
self.assertNotIn("C", test_option_dict)
|
||||
self.assertRaises(KeyError, lambda: test_option_dict["C"])
|
||||
|
||||
def test_option_set(self):
|
||||
class TestOptionSet(OptionSet):
|
||||
valid_keys = frozenset({"A", "B", "C"})
|
||||
|
||||
unknown_key_init_set = {"D"}
|
||||
test_option_set = TestOptionSet(unknown_key_init_set)
|
||||
self.assertRaises(OptionError, test_option_set.verify_keys)
|
||||
|
||||
init_set = {"A", "B"}
|
||||
test_option_set = TestOptionSet(init_set)
|
||||
|
||||
self.assertEqual(test_option_set, init_set) # Implicit value comparison
|
||||
self.assertIn("B", test_option_set)
|
||||
self.assertNotIn("C", test_option_set)
|
||||
|
||||
def test_option_list(self):
|
||||
class TestOptionList(OptionList):
|
||||
valid_keys = frozenset({"A", "B", "C"})
|
||||
|
||||
unknown_key_init_list = ["D"]
|
||||
test_option_list = TestOptionList(unknown_key_init_list)
|
||||
self.assertRaises(OptionError, test_option_list.verify_keys)
|
||||
|
||||
init_list = ["A", "B"]
|
||||
test_option_list = TestOptionList(init_list)
|
||||
|
||||
self.assertEqual(test_option_list, init_list)
|
||||
self.assertIn("B", test_option_list)
|
||||
self.assertNotIn("C", test_option_list)
|
||||
|
||||
|
||||
def test_option_counter(self):
|
||||
class TestOptionCounter(OptionCounter):
|
||||
valid_keys = frozenset({"A", "B", "C"})
|
||||
|
||||
max = 10
|
||||
min = 0
|
||||
|
||||
unknown_key_init_dict = {"D": 5}
|
||||
test_option_counter = TestOptionCounter(unknown_key_init_dict)
|
||||
self.assertRaises(OptionError, test_option_counter.verify_keys)
|
||||
|
||||
wrong_value_type_init_dict = {"A": "B"}
|
||||
self.assertRaises(TypeError, TestOptionCounter, wrong_value_type_init_dict)
|
||||
|
||||
violates_max_init_dict = {"A": 5, "B": 11}
|
||||
test_option_counter = TestOptionCounter(violates_max_init_dict)
|
||||
self.assertRaises(OptionError, test_option_counter.verify_values)
|
||||
|
||||
violates_min_init_dict = {"A": -1, "B": 5}
|
||||
test_option_counter = TestOptionCounter(violates_min_init_dict)
|
||||
self.assertRaises(OptionError, test_option_counter.verify_values)
|
||||
|
||||
init_dict = {"A": 0, "B": 10}
|
||||
test_option_counter = TestOptionCounter(init_dict)
|
||||
self.assertEqual(test_option_counter, Counter(init_dict))
|
||||
self.assertIn("A", test_option_counter)
|
||||
self.assertNotIn("C", test_option_counter)
|
||||
self.assertEqual(test_option_counter["A"], 0)
|
||||
self.assertEqual(test_option_counter["B"], 10)
|
||||
self.assertEqual(test_option_counter["C"], 0)
|
||||
|
||||
def test_culling_option_counter(self):
|
||||
class TestCullingCounter(OptionCounter):
|
||||
valid_keys = frozenset({"A", "B", "C"})
|
||||
cull_zeroes = True
|
||||
|
||||
init_dict = {"A": 0, "B": 10}
|
||||
test_option_counter = TestCullingCounter(init_dict)
|
||||
self.assertNotIn("A", test_option_counter)
|
||||
self.assertIn("B", test_option_counter)
|
||||
self.assertNotIn("C", test_option_counter)
|
||||
self.assertEqual(test_option_counter["A"], 0) # It's still a Counter! cull_zeroes is about "in" checks.
|
||||
self.assertEqual(test_option_counter, Counter({"B": 10}))
|
||||
@@ -115,7 +115,6 @@ class TestGenerateWeights(TestGenerateMain):
|
||||
settings = get_settings()
|
||||
settings.generator.player_files_path = settings.generator.PlayerFilesPath(self.yaml_input_dir)
|
||||
settings.generator.players = 5 # arbitrary number, should be enough
|
||||
settings.generator.race = 0 # make sure race mode is disabled so the below seed is actually respected
|
||||
settings._filename = None
|
||||
user_path_backup = user_path.cached_path
|
||||
user_path.cached_path = local_path()
|
||||
|
||||
@@ -31,16 +31,6 @@ class TestBase(unittest.TestCase):
|
||||
if "register_blueprint" not in e.args[0]:
|
||||
raise
|
||||
cls.app = raw_app
|
||||
except ValueError as e:
|
||||
# as above, for more recent versions of flask this is now a ValueError
|
||||
if "is already registered for this blueprint" not in e.args[0]:
|
||||
raise
|
||||
cls.app = raw_app
|
||||
|
||||
def setUp(self) -> None:
|
||||
from WebHostLib.models import db
|
||||
from pony.orm import db_session
|
||||
with db_session:
|
||||
for entity in db.entities.values():
|
||||
entity.select().delete(bulk=True)
|
||||
self.client = self.app.test_client()
|
||||
@@ -1,107 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import db_session, commit
|
||||
|
||||
from Utils import utcnow
|
||||
from WebHostLib.autolauncher import cleanup
|
||||
from WebHostLib.models import Room, Seed, Slot
|
||||
from . import TestBase
|
||||
|
||||
|
||||
class TestCleanup(TestBase):
|
||||
def test_cleanup_unowned(self) -> None:
|
||||
with db_session:
|
||||
s1 = Seed(id=uuid4(), multidata=b"", owner=UUID(int=0))
|
||||
Room(id=uuid4(), owner=UUID(int=0), seed=s1)
|
||||
|
||||
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4()) # Owned
|
||||
Room(id=uuid4(), owner=UUID(int=0), seed=s2) # Unowned room of owned seed
|
||||
|
||||
Seed(id=uuid4(), multidata=b"", owner=UUID(int=0)) # Unowned seed with no rooms
|
||||
|
||||
commit()
|
||||
|
||||
cleanup({"ROOM_AUTO_DELETE": 0})
|
||||
|
||||
with db_session:
|
||||
self.assertEqual(Room.select().count(), 0) # Both rooms were unowned
|
||||
self.assertEqual(Seed.select().count(), 1) # s2 is owned
|
||||
self.assertIsNotNone(Seed.get(id=s2.id))
|
||||
|
||||
def test_cleanup_auto_delete(self) -> None:
|
||||
now = utcnow()
|
||||
old_time = now - timedelta(days=10)
|
||||
recent_time = now - timedelta(days=2)
|
||||
|
||||
with db_session:
|
||||
# Case 1: Old room, owned
|
||||
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
|
||||
|
||||
# Case 2: Recent room, owned
|
||||
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
r2 = Room(id=uuid4(), owner=uuid4(), seed=s2, last_activity=recent_time)
|
||||
|
||||
# Case 3: Old seed, no rooms, owned
|
||||
s3 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
|
||||
# Case 4: Recent seed, no rooms, owned
|
||||
s4 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=recent_time)
|
||||
|
||||
# Case 5: Old seed with recent room (should not be deleted)
|
||||
s5 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
r5 = Room(id=uuid4(), owner=uuid4(), seed=s5, last_activity=recent_time)
|
||||
|
||||
commit()
|
||||
|
||||
# Delete items older than 5 days
|
||||
cleanup({"ROOM_AUTO_DELETE": 5})
|
||||
|
||||
with db_session:
|
||||
self.assertIsNone(Room.get(id=r1.id), "Old room should be deleted")
|
||||
self.assertIsNotNone(Room.get(id=r2.id), "Recent room should NOT be deleted")
|
||||
self.assertIsNone(Seed.get(id=s3.id), "Old seed without rooms should be deleted")
|
||||
self.assertIsNotNone(Seed.get(id=s4.id), "Recent seed without rooms should NOT be deleted")
|
||||
self.assertIsNotNone(Seed.get(id=s5.id), "Old seed with recent room should NOT be deleted")
|
||||
self.assertIsNotNone(Room.get(id=r5.id), "Recent room for old seed should NOT be deleted")
|
||||
|
||||
# Seeds are deleted if they have NO rooms AND are old.
|
||||
# After r1 is deleted, s1 has no rooms. Since it's old, it should be deleted.
|
||||
self.assertIsNone(Seed.get(id=s1.id), "Old seed whose only room was deleted should be deleted")
|
||||
|
||||
def test_cleanup_disabled(self) -> None:
|
||||
now = utcnow()
|
||||
old_time = now - timedelta(days=10)
|
||||
|
||||
with db_session:
|
||||
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
r1 = Room(id=uuid4(), owner=uuid4(), seed=s1, last_activity=old_time)
|
||||
commit()
|
||||
|
||||
cleanup({"ROOM_AUTO_DELETE": 0})
|
||||
|
||||
with db_session:
|
||||
self.assertIsNotNone(Room.get(id=r1.id), "Room should NOT be deleted when auto-delete is 0")
|
||||
self.assertIsNotNone(Seed.get(id=s1.id), "Seed should NOT be deleted when auto-delete is 0")
|
||||
|
||||
def test_cleanup_slots(self) -> None:
|
||||
now = utcnow()
|
||||
old_time = now - timedelta(days=10)
|
||||
|
||||
with db_session:
|
||||
s1 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=old_time)
|
||||
slot1 = Slot(player_id=1, player_name="P1", seed=s1, game="TestGame")
|
||||
|
||||
s2 = Seed(id=uuid4(), multidata=b"", owner=uuid4(), creation_time=now)
|
||||
slot2 = Slot(player_id=2, player_name="P2", seed=s2, game="TestGame")
|
||||
|
||||
commit()
|
||||
|
||||
# Delete items older than 5 days
|
||||
cleanup({"ROOM_AUTO_DELETE": 5})
|
||||
|
||||
with db_session:
|
||||
self.assertIsNone(Seed.get(id=s1.id), "Old seed should be deleted")
|
||||
self.assertIsNone(Slot.get(id=slot1.id), "Slot of deleted seed should be deleted")
|
||||
self.assertIsNotNone(Seed.get(id=s2.id), "Recent seed should NOT be deleted")
|
||||
self.assertIsNotNone(Slot.get(id=slot2.id), "Slot of recent seed should NOT be deleted")
|
||||
@@ -4,11 +4,9 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestWebDescriptions(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_item_descriptions_have_valid_names(self) -> None:
|
||||
"""Ensure all item descriptions match an item name or item group name"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
valid_names = world_type.item_names.union(world_type.item_name_groups)
|
||||
for name in world_type.web.item_descriptions:
|
||||
with self.subTest("Name should be valid", game=game_name, item=name):
|
||||
@@ -17,7 +15,7 @@ class TestWebDescriptions(unittest.TestCase):
|
||||
|
||||
def test_location_descriptions_have_valid_names(self) -> None:
|
||||
"""Ensure all location descriptions match a location name or location group name"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
valid_names = world_type.location_names.union(world_type.location_name_groups)
|
||||
for name in world_type.web.location_descriptions:
|
||||
with self.subTest("Name should be valid", game=game_name, location=name):
|
||||
|
||||
@@ -9,14 +9,12 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestDocs(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
WebHost.copy_tutorials_files_to_static()
|
||||
|
||||
def test_has_tutorial(self):
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
tutorials = world_type.web.tutorials
|
||||
@@ -31,7 +29,7 @@ class TestDocs(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_has_game_info(self):
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
safe_name = secure_filename(game_name)
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
||||
|
||||
@@ -7,8 +7,6 @@ import WebHost
|
||||
|
||||
|
||||
class TestFileGeneration(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
|
||||
|
||||
@@ -6,11 +6,9 @@ from Options import OptionCounter, NamedRange, NumericOption, OptionList, Option
|
||||
|
||||
|
||||
class TestOptionPresets(unittest.TestCase):
|
||||
world_relevant = True
|
||||
|
||||
def test_option_presets_have_valid_options(self):
|
||||
"""Test that all predefined option presets are valid options."""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
presets = world_type.web.options_presets
|
||||
for preset_name, preset in presets.items():
|
||||
for option_name, option_value in preset.items():
|
||||
@@ -40,7 +38,7 @@ class TestOptionPresets(unittest.TestCase):
|
||||
"""Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
|
||||
value.
|
||||
"""
|
||||
for game_name, world_type in AutoWorldRegister.testable_worlds.items():
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
presets = world_type.web.options_presets
|
||||
for preset_name, preset in presets.items():
|
||||
for option_name, option_value in preset.items():
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import os
|
||||
import unittest
|
||||
from socket import socket as Socket # noqa: N812
|
||||
|
||||
from Utils import is_macos
|
||||
from WebHostLib.customserver import RandomPortSocketCreator
|
||||
|
||||
ci = bool(os.environ.get("CI"))
|
||||
|
||||
|
||||
class TestPortAllocating(unittest.TestCase):
|
||||
def test_parse_game_ports(self) -> None:
|
||||
"""Ensure that game ports with ranges are parsed correctly"""
|
||||
val = RandomPortSocketCreator._parse_game_ports(("1000-2000", "2000-5000", "1000-2000", 20, 40, "20", "0"))
|
||||
|
||||
self.assertCountEqual(val.valid_ports,
|
||||
[*range(1000, 2001), *range(2000, 5001), *range(1000, 2001), 20, 40, 20],
|
||||
"The parsed game ports are not the expected length")
|
||||
self.assertTrue(val.ephemeral_allowed, "The ephemeral allowed flag is not set even though it was passed")
|
||||
|
||||
val = RandomPortSocketCreator._parse_game_ports(())
|
||||
self.assertListEqual(val.valid_ports, [], "Empty list of game port returned something")
|
||||
self.assertFalse(val.ephemeral_allowed, "Empty list returned that ephemeral is allowed")
|
||||
|
||||
val = RandomPortSocketCreator._parse_game_ports((0,))
|
||||
self.assertListEqual(val.valid_ports, [], "Empty list of ranges returned something")
|
||||
self.assertTrue(val.ephemeral_allowed, "List with just 0 is not allowing ephemeral ports")
|
||||
|
||||
val = RandomPortSocketCreator._parse_game_ports((1,))
|
||||
self.assertListEqual(val.valid_ports, [1], "Valid ports doesn't contain the expected values")
|
||||
self.assertFalse(val.ephemeral_allowed, "List with just single port returned that ephemeral is allowed")
|
||||
|
||||
def test_parse_game_port_errors(self) -> None:
|
||||
"""Ensure that game ports with incorrect values raise the expected error"""
|
||||
with self.assertRaises(ValueError, msg="Negative numbers didn't get interpreted as an invalid range"):
|
||||
RandomPortSocketCreator._parse_game_ports(tuple("-50215"))
|
||||
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number"):
|
||||
RandomPortSocketCreator._parse_game_ports(tuple("dwafawg"))
|
||||
with self.assertRaises(
|
||||
ValueError,
|
||||
msg="A range with an extra dash at the end didn't get interpreted as an invalid number because of it's end dash"
|
||||
):
|
||||
RandomPortSocketCreator._parse_game_ports(tuple("20-21215-"))
|
||||
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number for the start of a range"):
|
||||
RandomPortSocketCreator._parse_game_ports(tuple("f-21215"))
|
||||
|
||||
def test_random_port_socket_edge_cases(self) -> None:
|
||||
"""Verify if edge cases on creation of random port socket is working fine"""
|
||||
# Try giving an empty tuple and fail over it
|
||||
creator = RandomPortSocketCreator(())
|
||||
with self.assertRaises(OSError) as err:
|
||||
creator.create("127.0.0.1")
|
||||
self.assertEqual(err.exception.errno, 98, "Raised an unexpected error code")
|
||||
self.assertEqual(err.exception.strerror, "No available ports", "Raised an unexpected error string")
|
||||
|
||||
# Try only having ephemeral ports enabled
|
||||
creator = RandomPortSocketCreator(("0",))
|
||||
try:
|
||||
creator.create("127.0.0.1").close()
|
||||
except OSError as err:
|
||||
self.assertEqual(err.errno, 98, "Raised an unexpected error code")
|
||||
# If it returns our error string that means something is wrong with our code
|
||||
self.assertNotEqual(err.strerror, "No available ports",
|
||||
"Raised an unexpected error string")
|
||||
|
||||
@unittest.skipUnless(ci, "can't guarantee free ports outside of CI")
|
||||
def test_random_port_socket(self) -> None:
|
||||
"""Verify if returned sockets use the correct port ranges"""
|
||||
creator = RandomPortSocketCreator(("8080-8085",))
|
||||
sockets: list[Socket] = []
|
||||
for _ in range(6):
|
||||
socket = creator.create("127.0.0.1")
|
||||
sockets.append(socket)
|
||||
_, port = socket.getsockname()
|
||||
self.assertIn(port, range(8080, 8086), "Port of socket was not inside the expected range")
|
||||
for s in sockets:
|
||||
s.close()
|
||||
|
||||
sockets.clear()
|
||||
creator = RandomPortSocketCreator(("30000-65535",))
|
||||
length = 5_000 if is_macos else (30_000 - len(creator._get_used_ports()))
|
||||
for _ in range(length):
|
||||
socket = creator.create("127.0.0.1")
|
||||
sockets.append(socket)
|
||||
_, port = socket.getsockname()
|
||||
self.assertIn(port, range(30_000, 65536), "Port of socket was not inside the expected range")
|
||||
|
||||
for s in sockets:
|
||||
s.close()
|
||||
@@ -1,76 +0,0 @@
|
||||
import math
|
||||
from typing import Any, Callable
|
||||
from typing_extensions import override
|
||||
from uuid import uuid4
|
||||
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from . import TestBase
|
||||
|
||||
|
||||
class TestSUUID(TestBase):
|
||||
converter: BaseConverter
|
||||
filter: Callable[[Any], str]
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
from werkzeug.routing import Map
|
||||
|
||||
super().setUp()
|
||||
self.converter = self.app.url_map.converters["suuid"](Map())
|
||||
self.filter = self.app.jinja_env.filters["suuid"] # type: ignore # defines how we use it, not what it can be
|
||||
|
||||
def test_is_reversible(self) -> None:
|
||||
u = uuid4()
|
||||
self.assertEqual(u, self.converter.to_python(self.converter.to_url(u)))
|
||||
s = "A" * 22 # uuid with all zeros
|
||||
self.assertEqual(s, self.converter.to_url(self.converter.to_python(s)))
|
||||
|
||||
def test_uuid_length(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python("AAAA")
|
||||
|
||||
def test_padding(self) -> None:
|
||||
self.converter.to_python("A" * 22) # check that the correct value works
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python("A" * 22 + "==") # converter should not allow padding
|
||||
|
||||
def test_empty(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python("")
|
||||
|
||||
def test_stray_equal_signs(self) -> None:
|
||||
self.converter.to_python("A" * 22) # check that the correct value works
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python("A" * 22 + "==" + "AA") # the "==AA" should not be ignored, but error out
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python("A" * 20 + "==" + "AA") # the final "A"s should not be appended to the first "A"s
|
||||
|
||||
def test_stray_whitespace(self) -> None:
|
||||
s = "A" * 22
|
||||
self.converter.to_python(s) # check that the correct value works
|
||||
for char in " \t\r\n\v":
|
||||
for pos in (0, 11, 22):
|
||||
with self.subTest(char=char, pos=pos):
|
||||
s_with_whitespace = s[0:pos] + char * 4 + s[pos:] # insert 4 to make padding correct
|
||||
# check that the constructed s_with_whitespace is correct
|
||||
self.assertEqual(len(s_with_whitespace), len(s) + 4)
|
||||
self.assertEqual(s_with_whitespace[pos], char)
|
||||
# s_with_whitespace should be invalid as SUUID
|
||||
with self.assertRaises(ValueError):
|
||||
self.converter.to_python(s_with_whitespace)
|
||||
|
||||
def test_filter_returns_valid_string(self) -> None:
|
||||
u = uuid4()
|
||||
s = self.filter(u)
|
||||
self.assertIsInstance(s, str)
|
||||
self.assertNotIn("=", s)
|
||||
self.assertEqual(len(s), math.ceil(len(u.bytes) * 4 / 3))
|
||||
|
||||
def test_filter_is_same_as_converter(self) -> None:
|
||||
u = uuid4()
|
||||
self.assertEqual(self.filter(u), self.converter.to_url(u))
|
||||
|
||||
def test_filter_bad_type(self) -> None:
|
||||
with self.assertRaises(Exception): # currently the type is not checked directly, so any exception is valid
|
||||
self.filter(None)
|
||||
@@ -35,7 +35,7 @@ def load_tests(loader: "TestLoader", standard_tests: "TestSuite", pattern: str):
|
||||
|
||||
|
||||
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
|
||||
for world in AutoWorldRegister.testable_worlds.values()
|
||||
for world in AutoWorldRegister.world_types.values()
|
||||
if fnmatch.fnmatch(world.__module__, world_glob)]
|
||||
|
||||
all_tests = [
|
||||
|
||||
+3
-8
@@ -28,9 +28,7 @@ class InvalidItemError(KeyError):
|
||||
|
||||
|
||||
class AutoWorldRegister(type):
|
||||
world_types: dict[str, Type[World]] = {}
|
||||
testable_worlds: dict[str, Type[World]] = world_types
|
||||
"""worlds under test; scoped to AP_TEST_WORLDS by worlds/__init__"""
|
||||
world_types: Dict[str, Type[World]] = {}
|
||||
__file__: str
|
||||
zip_path: Optional[str]
|
||||
settings_key: str
|
||||
@@ -355,14 +353,13 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""path it was loaded from"""
|
||||
world_version: ClassVar[Version] = Version(0, 0, 0)
|
||||
"""Optional world version loaded from archipelago.json"""
|
||||
manifest: ClassVar[dict[str, Any]] = {}
|
||||
"""Mapping of the world's archipelago.json manifest. Use game and world_version attrs instead for those values."""
|
||||
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
assert multiworld is not None
|
||||
self.multiworld = multiworld
|
||||
self.player = player
|
||||
self.random = Random(multiworld.random.getrandbits(64))
|
||||
multiworld.per_slot_randoms[player] = self.random
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "settings":
|
||||
@@ -515,9 +512,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
"""
|
||||
If core AP removes an item from your item pool, this method is called to choose a replacement item
|
||||
so item count and location count remain equal.
|
||||
For example: plando, item_links and start_inventory_from_pool are features that may cause this.
|
||||
Called when the item pool needs to be filled with additional items to match location count.
|
||||
|
||||
Any returned item name must be for a "repeatable" item, i.e. one that it's okay to generate arbitrarily many of.
|
||||
For most worlds this will be one or more of your filler items, but the classification of these items
|
||||
|
||||
+20
-161
@@ -2,22 +2,19 @@ import bisect
|
||||
import logging
|
||||
import pathlib
|
||||
import weakref
|
||||
import sys
|
||||
import webbrowser
|
||||
from enum import Enum
|
||||
from typing import Optional, Callable, Iterable, Sequence
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Callable, List, Iterable, Tuple
|
||||
|
||||
from Launcher import launch as launch_exe
|
||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore, \
|
||||
is_windows
|
||||
from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore
|
||||
|
||||
|
||||
class Type(str, Enum):
|
||||
TOOL = "TOOL"
|
||||
MISC = "MISC"
|
||||
CLIENT = "CLIENT"
|
||||
ADJUSTER = "ADJUSTER"
|
||||
HIDDEN = "HIDDEN"
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
MISC = auto()
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
FUNC = auto() # do not use anymore
|
||||
HIDDEN = auto()
|
||||
|
||||
|
||||
class Component:
|
||||
@@ -70,6 +67,10 @@ class Component:
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
if component_type == Type.FUNC:
|
||||
from Utils import deprecate
|
||||
deprecate(f"Launcher Component {self.display_name} is using Type.FUNC Type, which is pending removal.")
|
||||
component_type = Type.MISC
|
||||
|
||||
self.type = component_type or (
|
||||
Type.CLIENT if "Client" in display_name else
|
||||
@@ -85,27 +86,18 @@ class Component:
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.display_name})"
|
||||
|
||||
def run(self, *args) -> bool:
|
||||
if self.func:
|
||||
self.func(*args)
|
||||
elif self.script_name:
|
||||
import subprocess
|
||||
subprocess.run([*get_exe(self.script_name), *args])
|
||||
else:
|
||||
logging.warning(f"Component {self} does not appear to be executable.")
|
||||
|
||||
|
||||
processes = weakref.WeakSet()
|
||||
|
||||
|
||||
def launch_subprocess(func: Callable, name: str | None = None, args: tuple[str, ...] = ()) -> None:
|
||||
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
import multiprocessing
|
||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
|
||||
def launch(func: Callable, name: str | None = None, args: tuple[str, ...] = ()) -> None:
|
||||
def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||
from Utils import is_kivy_running
|
||||
if is_kivy_running():
|
||||
launch_subprocess(func, name, args)
|
||||
@@ -132,7 +124,7 @@ def launch_textclient(*args):
|
||||
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||
|
||||
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[tuple[pathlib.Path, pathlib.Path]]:
|
||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||
if not apworld_src:
|
||||
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
|
||||
if not apworld_src:
|
||||
@@ -190,9 +182,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[tuple[pathlib.Path, path
|
||||
"so a Launcher restart is required to use the new installation.")
|
||||
world_source = worlds.WorldSource(str(target), is_zip=True, relative=False)
|
||||
bisect.insort(worlds.world_sources, world_source)
|
||||
worlds.add_apworld_spec(world_source, worlds.APWorldContainer(world_source.resolved_path))
|
||||
if not world_source.load():
|
||||
raise Exception(f"World was installed, but failed to load. See the Launcher log for details.")
|
||||
world_source.load()
|
||||
|
||||
return apworld_path, target
|
||||
|
||||
@@ -225,124 +215,8 @@ def export_datapackage() -> None:
|
||||
|
||||
open_file(path)
|
||||
|
||||
def open_patch():
|
||||
from Utils import messagebox
|
||||
from os.path import isfile
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) and \
|
||||
(c.script_name is None or isfile(get_exe(c)[-1])):
|
||||
suffixes += c.file_identifier.suffixes
|
||||
try:
|
||||
filename = open_filename("Select patch", (("Patches", suffixes),))
|
||||
except Exception as e:
|
||||
messagebox("Error", str(e), error=True)
|
||||
else:
|
||||
file, component = identify(filename)
|
||||
if file and component:
|
||||
exe = get_exe(component)
|
||||
if exe is None or not isfile(exe[-1]):
|
||||
exe = get_exe("Launcher")
|
||||
|
||||
launch_exe([*exe, file], component.cli)
|
||||
|
||||
|
||||
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith("Archipelago"):
|
||||
name = name[11:]
|
||||
if name.endswith(".exe"):
|
||||
name = name[:-4]
|
||||
if name.endswith(".py"):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f"Archipelago{name}":
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = ".exe" if is_windows else ""
|
||||
return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
|
||||
else:
|
||||
return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
|
||||
|
||||
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component
|
||||
elif path == component.display_name or path == component.script_name:
|
||||
return None, component
|
||||
return None, None
|
||||
|
||||
def open_host_yaml():
|
||||
import settings
|
||||
import subprocess
|
||||
from shutil import which
|
||||
from Utils import is_linux, is_macos, env_cleared_lib_path
|
||||
s = settings.get_settings()
|
||||
file = s.filename
|
||||
s.save()
|
||||
assert file, "host.yaml missing"
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
else:
|
||||
webbrowser.open(file)
|
||||
return
|
||||
|
||||
env = env_cleared_lib_path()
|
||||
subprocess.Popen([exe, file], env=env)
|
||||
|
||||
def generate_yamls(*args):
|
||||
import argparse
|
||||
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
|
||||
parser.add_argument("--skip_open_folder", action="store_true")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target = user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
if not args.skip_open_folder:
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
open_folder(user_path())
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
import subprocess
|
||||
from shutil import which
|
||||
from Utils import is_linux, is_macos, env_cleared_lib_path
|
||||
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
else:
|
||||
webbrowser.open(folder_path)
|
||||
return
|
||||
|
||||
if exe:
|
||||
env = env_cleared_lib_path()
|
||||
subprocess.Popen([exe, folder_path], env=env)
|
||||
else:
|
||||
logging.warning(f"No file browser available to open {folder_path}")
|
||||
|
||||
|
||||
components: list[Component] = [
|
||||
components: List[Component] = [
|
||||
# Launcher
|
||||
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
||||
# Core
|
||||
@@ -357,22 +231,6 @@ components: list[Component] = [
|
||||
description="Install an APWorld to play games not included with Archipelago by default."),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
||||
description="Connect to a multiworld using the text client."),
|
||||
# Functions
|
||||
Component("Open host.yaml", func=open_host_yaml,
|
||||
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||
Component("Open Patch", func=open_patch,
|
||||
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||
Component("Generate Template Options", func=generate_yamls,
|
||||
description="Generate template YAMLs for currently installed games."),
|
||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||
description="Open archipelago.gg in your browser."),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||
Component("Unrated/18+ Discord Server", icon="discord",
|
||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||
Component("Browse Files", func=browse_files,
|
||||
description="Open the Archipelago installation folder in your file browser."),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
@@ -408,6 +266,7 @@ if not is_frozen():
|
||||
|
||||
from worlds import AutoWorldRegister
|
||||
from worlds.Files import APWorldContainer
|
||||
from Launcher import open_folder
|
||||
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(prog="Build APWorlds", description="Build script for APWorlds")
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .Items import pokemon_stadium_items, gym_badge_codes, box_upgrade_items, cup_tier_upgrade_items
|
||||
from .Locations import pokemon_stadium_locations, event_locations
|
||||
from NetUtils import ClientStatus
|
||||
from .Types import LocData
|
||||
import Utils
|
||||
import worlds._bizhawk as bizhawk
|
||||
from worlds._bizhawk.client import BizHawkClient
|
||||
|
||||
logger = logging.getLogger('Client')
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
|
||||
class PokemonStadiumClient(BizHawkClient):
|
||||
game = 'Pokemon Stadium'
|
||||
system = 'N64'
|
||||
patch_suffix = '.apstadium'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.local_checked_locations = set()
|
||||
self.glc_loaded = False
|
||||
self.cups_loaded = False
|
||||
self.minigame_index = None
|
||||
self.minigame_done = False
|
||||
self.minigame_check_sent = False
|
||||
|
||||
async def validate_rom(self, ctx: 'BizHawkClientContext') -> bool:
|
||||
try:
|
||||
# Check ROM name
|
||||
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(0x20, 15, 'ROM')]))[0]).decode('ascii')
|
||||
if rom_name != 'POKEMON STADIUM':
|
||||
logger.info('Invalid ROM for Pokemon Stadium AP World')
|
||||
return False
|
||||
except bizhawk.RequestFailedError:
|
||||
return False
|
||||
|
||||
ctx.game = self.game
|
||||
ctx.items_handling = 0b111
|
||||
ctx.want_slot_data = True
|
||||
|
||||
return True
|
||||
|
||||
async def game_watcher(self, ctx: 'BizHawkClientContext') -> None:
|
||||
item_codes = {net_item.item for net_item in ctx.items_received}
|
||||
|
||||
flags = await bizhawk.read(ctx.bizhawk_ctx, [
|
||||
(0x420000, 4, 'RDRAM'), # GLC Flag
|
||||
(0x420010, 4, 'RDRAM'), # Entered Battle Flag
|
||||
(0x148AC8, 12, 'RDRAM'), # Beat Rival Flag
|
||||
(0x12FC1C, 4, 'RDRAM'), # Minigame being played
|
||||
(0x124860, 4, 'RDRAM'), # Minigame results
|
||||
(0xAE77F, 1, 'RDRAM'), # Enemy team HP slot 1
|
||||
(0xAE7D3, 1, 'RDRAM'), # Enemy team HP slot 2
|
||||
(0xAE827, 1, 'RDRAM'), # Enemy team HP slot 3
|
||||
(0x220C19, 3, 'RDRAM'), # GLC Rentals address
|
||||
(0x221D99, 3, 'RDRAM'), # GLC Registration table address
|
||||
(0x218CE9, 3, 'RDRAM'), # Poke Cup Rentals address
|
||||
(0x219E69, 3, 'RDRAM'), # Poke Cup Registration table address
|
||||
(0x218CB9, 3, 'RDRAM'), # Prime Cup Rentals address
|
||||
(0x219E39, 3, 'RDRAM'), # Prime Cup Registration table address
|
||||
(0x218C99, 3, 'RDRAM'), # Petit Cup Rentals address
|
||||
(0x219E19, 3, 'RDRAM'), # Petit Cup Registration table address
|
||||
(0x218CA9, 3, 'RDRAM'), # Pika Cup Rentals address
|
||||
(0x219E29, 3, 'RDRAM'), # Pika Cup Registration table address
|
||||
(0x420020, 4, 'RDRAM'), # Picking a Cup tier
|
||||
]
|
||||
)
|
||||
|
||||
player_has_battled = flags[1] != b'\x00\x00\x00\x00'
|
||||
battle_info = await bizhawk.read(ctx.bizhawk_ctx, [(0x0AE540, 4, 'RDRAM')])
|
||||
mode = int(battle_info[0].hex()[:2])
|
||||
gym_info = battle_info[0].hex()[4:]
|
||||
gym_number = int(battle_info[0].hex()[4:6])
|
||||
trainer_index = int(battle_info[0].hex()[6:])
|
||||
|
||||
if player_has_battled:
|
||||
player_won = all(x == b'\x00' for x in flags[5:8])
|
||||
|
||||
if player_won:
|
||||
ap_code = 20000000 + (mode * 100) + (gym_number * 10) + trainer_index
|
||||
|
||||
# If a Gym Leader was beaten or the last trainer for a Cup was beaten an additional check must be sent
|
||||
if mode == 7 and trainer_index == 4:
|
||||
locations_to_check = set([ap_code, ap_code + 1])
|
||||
elif trainer_index == 8:
|
||||
locations_to_check = set([ap_code, ap_code - trainer_index, ap_code + 1])
|
||||
else:
|
||||
locations_to_check = set([ap_code])
|
||||
|
||||
try:
|
||||
await ctx.check_locations(locations_to_check)
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(0x420010, [0x00, 0x00, 0x00, 0x00], 'RDRAM')])
|
||||
self.glc_loaded = False
|
||||
except:
|
||||
pass
|
||||
|
||||
glc_flag = int.from_bytes(flags[0], byteorder='big')
|
||||
if glc_flag == 2 and not self.glc_loaded:
|
||||
self.glc_loaded = True
|
||||
|
||||
self.GLC_UNLOCK_FLAGS = [
|
||||
0x147B70, # Pewter
|
||||
0x147B98, # Cerulean
|
||||
0x147BC0, # Vermilion
|
||||
0x147BE8, # Celadon
|
||||
0x147C10, # Fuchsia
|
||||
0x147C38, # Saffron
|
||||
0x147C60, # Cinnabar
|
||||
0x147C88, # Viridian
|
||||
0x147CB1, # E4 entrance
|
||||
0x147CD9, # E4 exit
|
||||
0x147D01, # E4
|
||||
]
|
||||
|
||||
# UUDDLLRR
|
||||
self.GLC_CURSOR_TARGETS = [
|
||||
0x147B84, # Brock, 00000002
|
||||
0x147BAC, # Misty, 03000103
|
||||
0x147BD4, # Surge, 04020200
|
||||
0x147BFC, # Erika, 05030500
|
||||
0x147C24, # Koga, 06040604
|
||||
0x147C4C, # Sabrina, 07050007
|
||||
0x147C74, # Blaine, 00080608
|
||||
0x147C9C, # Giovanni, 07000709
|
||||
]
|
||||
|
||||
gym_codes = [
|
||||
pokemon_stadium_items['Pewter City Key'].ap_code,
|
||||
pokemon_stadium_items['Cerulean City Key'].ap_code,
|
||||
pokemon_stadium_items['Vermillion City Key'].ap_code,
|
||||
pokemon_stadium_items['Celadon City Key'].ap_code,
|
||||
pokemon_stadium_items['Fuchsia City Key'].ap_code,
|
||||
pokemon_stadium_items['Saffron City Key'].ap_code,
|
||||
pokemon_stadium_items['Cinnabar Island Key'].ap_code,
|
||||
pokemon_stadium_items['Viridian City Key'].ap_code,
|
||||
]
|
||||
|
||||
self.unlocked_gyms = [i + 1 for i, code in enumerate(gym_codes) if code in item_codes]
|
||||
victory_road_open = set(gym_badge_codes).issubset(item_codes)
|
||||
if victory_road_open:
|
||||
self.unlocked_gyms.append(9)
|
||||
|
||||
if gym_codes[0] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[0], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_brock_cursor(ctx)
|
||||
|
||||
if gym_codes[1] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[1], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_misty_cursor(ctx)
|
||||
|
||||
if gym_codes[2] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[2], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_surge_cursor(ctx)
|
||||
|
||||
if gym_codes[3] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[3], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_erika_cursor(ctx)
|
||||
|
||||
if gym_codes[4] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[4], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_koga_cursor(ctx)
|
||||
|
||||
if gym_codes[5] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[5], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_sabrina_cursor(ctx)
|
||||
|
||||
if gym_codes[6] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[6], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_blaine_cursor(ctx)
|
||||
|
||||
if gym_codes[7] in item_codes:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[7], [0x00, 0x01], 'RDRAM')])
|
||||
await self.update_giovanni_cursor(ctx, item_codes)
|
||||
|
||||
if victory_road_open:
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[8], [0x01], 'RDRAM')])
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[9], [0x01], 'RDRAM')])
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_UNLOCK_FLAGS[10], [0x01], 'RDRAM')])
|
||||
|
||||
if len(self.unlocked_gyms) > 0 and gym_info != '0804':
|
||||
first_gym = self.unlocked_gyms[0] - 1
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(0x147D50, [0x00, first_gym], 'RDRAM')])
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(0x146F38, [0x52, 0x61, 0xFF, 0x82], 'RDRAM')])
|
||||
elif glc_flag != 2 and self.glc_loaded:
|
||||
self.glc_loaded = False
|
||||
|
||||
text = flags[2].decode("ascii", errors="ignore")
|
||||
if text == 'Magnificent!':
|
||||
await ctx.check_locations(set([event_locations['Beat Rival'].ap_code]))
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(0x420010, [0x00, 0x00, 0x00, 0x00], 'RDRAM')])
|
||||
|
||||
cups_flag = int.from_bytes(flags[18], byteorder='big')
|
||||
if cups_flag != 0 and not self.cups_loaded:
|
||||
self.cups_loaded = True
|
||||
|
||||
if mode == 3:
|
||||
cup_tier_item = cup_tier_upgrade_items['Poké Cup - Tier Upgrade'].ap_code
|
||||
else:
|
||||
cup_tier_item = cup_tier_upgrade_items['Prime Cup - Tier Upgrade'].ap_code
|
||||
|
||||
cup_tier = sum(1 for net_item in ctx.items_received if net_item.item == cup_tier_item)
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(0x147018, [0x00, 0x00, 0x00, cup_tier], 'RDRAM')])
|
||||
elif cups_flag == 0:
|
||||
self.cups_loaded = False
|
||||
|
||||
# GLC Boxes
|
||||
selecting_team = flags[8] == b'\x22\x0E\x20'
|
||||
registering_team = flags[9] == b'\x22\x1F\xA0'
|
||||
if selecting_team or registering_team:
|
||||
address = 0x220E23 if selecting_team else 0x221FA3
|
||||
item = box_upgrade_items['GLC PC Box Upgrade'].ap_code
|
||||
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
|
||||
table_size = 29 + 20 * box_count
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
|
||||
|
||||
# Poke Boxes
|
||||
selecting_team = flags[10] == b'\x21\x8F\x10'
|
||||
registering_team = flags[11] == b'\x21\xA0\x90'
|
||||
if selecting_team or registering_team:
|
||||
address = 0x218F13 if selecting_team else 0x21A093
|
||||
item = box_upgrade_items['Poke Cup PC Box Upgrade'].ap_code
|
||||
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
|
||||
table_size = 29 + 20 * box_count
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
|
||||
|
||||
# Prime Boxes
|
||||
selecting_team = flags[12] == b'\x21\x8F\x10'
|
||||
registering_team = flags[13] == b'\x21\xA0\x90'
|
||||
if selecting_team or registering_team:
|
||||
address = 0x218F13 if selecting_team else 0x21A093
|
||||
item = box_upgrade_items['Prime Cup PC Box Upgrade'].ap_code
|
||||
box_count = sum(1 for net_item in ctx.items_received if net_item.item == item)
|
||||
table_size = 29 + 20 * box_count
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(address, [table_size], 'RDRAM')])
|
||||
|
||||
# Minigames
|
||||
if flags[3].startswith(b'\x00\x03\x00') and flags[3][3] in range(9):
|
||||
self.minigame_index = flags[3][3]
|
||||
|
||||
if self.minigame_index != None and flags[4] == b'\x00\x00\x00\x00':
|
||||
self.minigame_done = False
|
||||
|
||||
if self.minigame_index != None and not self.minigame_done and flags[4] == b'\x01\x00\x00\x00':
|
||||
self.minigame_done = True
|
||||
self.minigame_check_sent = False
|
||||
|
||||
if self.minigame_done and self.minigame_index != None and not self.minigame_check_sent:
|
||||
minigame_ap_acode = 20000100 + self.minigame_index
|
||||
await ctx.check_locations([minigame_ap_acode])
|
||||
|
||||
self.minigame_check_sent = True
|
||||
|
||||
# Send game clear
|
||||
if not ctx.finished_game and pokemon_stadium_items['Victory'].ap_code in item_codes:
|
||||
ctx.finished_game = True
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL,
|
||||
}])
|
||||
|
||||
def lowest_unlocked_from(self, lower_bound):
|
||||
for i in range(lower_bound, 9):
|
||||
if i in self.unlocked_gyms:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def highest_unlocked_from(self, upper_bound):
|
||||
for i in range(upper_bound, 0, -1):
|
||||
if i in self.unlocked_gyms:
|
||||
return i
|
||||
return 0
|
||||
|
||||
async def update_brock_cursor(self, ctx):
|
||||
# Determine UP: lowest unlocked gym from 4 to 9
|
||||
up = self.lowest_unlocked_from(4)
|
||||
|
||||
# Determine RIGHT: lowest of 2 or 3 or 4 if any are unlocked
|
||||
right = 0
|
||||
misty_unlocked = 2 in self.unlocked_gyms
|
||||
surge_unlocked = 3 in self.unlocked_gyms
|
||||
erika_unlocked = 4 in self.unlocked_gyms
|
||||
|
||||
if misty_unlocked:
|
||||
right = 2
|
||||
elif surge_unlocked:
|
||||
right = 3
|
||||
elif erika_unlocked:
|
||||
right = 4
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[0], [up, 0x00, 0x00, right], 'RDRAM')])
|
||||
|
||||
async def update_misty_cursor(self, ctx):
|
||||
# Determine UP: lowest unlocked gym from 4 to 9
|
||||
up = self.lowest_unlocked_from(4)
|
||||
|
||||
# Determine LEFT: is Brock unlocked
|
||||
left = 1 if 1 in self.unlocked_gyms else 0
|
||||
|
||||
# Determine RIGHT: is Surge unlocked
|
||||
right = 3 if 3 in self.unlocked_gyms else 0
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[1], [up, 0x00, left, right], 'RDRAM')])
|
||||
|
||||
async def update_surge_cursor(self, ctx):
|
||||
# Determine UP: lowest unlocked gym from 4 to 9
|
||||
up = self.lowest_unlocked_from(4)
|
||||
|
||||
# Determine DOWN: is Misty unlocked
|
||||
down = 2 if 2 in self.unlocked_gyms else 0
|
||||
|
||||
# Determine LEFT: is Misty or Brock unlocked
|
||||
left = 0
|
||||
misty_unlocked = 2 if 2 in self.unlocked_gyms else 0
|
||||
brock_unlocked = 1 if 1 in self.unlocked_gyms else 0
|
||||
|
||||
if misty_unlocked:
|
||||
left = 2
|
||||
elif brock_unlocked:
|
||||
left = 1
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[2], [up, down, left, 0x00], 'RDRAM')])
|
||||
|
||||
async def update_erika_cursor(self, ctx):
|
||||
# Determine UP: lowest unlocked gym from 5 to 9
|
||||
up = self.lowest_unlocked_from(5)
|
||||
|
||||
# Determine DOWN: highest unlocked gym from 3 to 1
|
||||
down = self.highest_unlocked_from(3)
|
||||
|
||||
# Determine LEFT: is Koga or Sabrina unlocked
|
||||
left = 0
|
||||
koga_unlocked = 5 if 5 in self.unlocked_gyms else 0
|
||||
sabrina_unlocked = 6 if 6 in self.unlocked_gyms else 0
|
||||
|
||||
if koga_unlocked:
|
||||
left = 5
|
||||
elif sabrina_unlocked:
|
||||
left = 6
|
||||
|
||||
# Determine RIGHT: is Surge unlocked
|
||||
right = 3 if 3 in self.unlocked_gyms else 0
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[3], [up, down, left, right], 'RDRAM')])
|
||||
|
||||
async def update_koga_cursor(self, ctx):
|
||||
# Determine UP: lowest unlocked gym from 6 to 9
|
||||
up = self.lowest_unlocked_from(6)
|
||||
|
||||
# Determine DOWN: highest unlocked gym from 2 to 1
|
||||
down = self.highest_unlocked_from(2)
|
||||
|
||||
# Determine LEFT: is Sabrina unlocked
|
||||
left = 6 if 6 in self.unlocked_gyms else 0
|
||||
|
||||
# Determine RIGHT: is Erika or Surge unlocked
|
||||
right = 0
|
||||
erika_unlocked = 4 if 4 in self.unlocked_gyms else 0
|
||||
surge_unlocked = 3 if 3 in self.unlocked_gyms else 0
|
||||
|
||||
if erika_unlocked:
|
||||
right = 4
|
||||
elif surge_unlocked:
|
||||
right = 3
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[4], [up, down, left, right], 'RDRAM')])
|
||||
|
||||
async def update_sabrina_cursor(self, ctx):
|
||||
# Determine DOWN: highest unlocked gym from 5 to 1
|
||||
down = self.highest_unlocked_from(5)
|
||||
|
||||
# Determine RIGHT: is Blaine or Giovanni unlocked
|
||||
right = 0
|
||||
blaine_unlocked = 7 if 7 in self.unlocked_gyms else 0
|
||||
giovanni_unlocked = 8 if 8 in self.unlocked_gyms else 0
|
||||
|
||||
if blaine_unlocked:
|
||||
right = 7
|
||||
elif giovanni_unlocked:
|
||||
right = 8
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[5], [0x00, down, 0x00, right], 'RDRAM')])
|
||||
|
||||
async def update_blaine_cursor(self, ctx):
|
||||
# Determine DOWN: highest unlocked gym from 5 to 1
|
||||
down = self.highest_unlocked_from(5)
|
||||
|
||||
# Determine LEFT: is Sabrina unlocked
|
||||
left = 6 if 6 in self.unlocked_gyms else 0
|
||||
|
||||
# Determine RIGHT: is Giovanni unlocked or do you have all badges needed
|
||||
if 8 in self.unlocked_gyms:
|
||||
right = 8
|
||||
elif 9 in self.unlocked_gyms:
|
||||
right = 9
|
||||
else:
|
||||
right = 0
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[6], [0x00, down, left, right], 'RDRAM')])
|
||||
|
||||
async def update_giovanni_cursor(self, ctx, item_codes):
|
||||
# Determine UP: All badges obtained?
|
||||
up = 9 if set(gym_badge_codes).issubset(item_codes) else 0
|
||||
|
||||
# Determine DOWN: highest unlocked gym from 5 to 1
|
||||
down = self.highest_unlocked_from(5)
|
||||
|
||||
# Determine LEFT: is Blaine or Sabrina unlocked
|
||||
left = 0
|
||||
blaine_unlocked = 7 if 7 in self.unlocked_gyms else 0
|
||||
sabrina_unlocked = 6 if 6 in self.unlocked_gyms else 0
|
||||
|
||||
if blaine_unlocked:
|
||||
left = 7
|
||||
elif sabrina_unlocked:
|
||||
left = 6
|
||||
|
||||
# Determine RIGHT: All badges obtained?
|
||||
right = up
|
||||
|
||||
await bizhawk.write(ctx.bizhawk_ctx, [(self.GLC_CURSOR_TARGETS[7], [up, down, left, right], 'RDRAM')])
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
|
||||
from .Types import ItemData, PokemonStadiumItem
|
||||
from .Locations import get_total_locations
|
||||
from typing import List, Dict, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PokemonStadiumWorld
|
||||
|
||||
def create_itempool(world: 'PokemonStadiumWorld') -> List[Item]:
|
||||
item_pool: List[Item] = []
|
||||
|
||||
# This is a good place to grab anything you need from options
|
||||
|
||||
for name in pokemon_stadium_items:
|
||||
if name != 'Victory' and name not in world.starting_gym_keys:
|
||||
item_pool.append(create_item(world, name))
|
||||
|
||||
victory = create_item(world, 'Victory')
|
||||
world.multiworld.get_location('Beat Rival', world.player).place_locked_item(victory)
|
||||
|
||||
item_pool += create_multiple_items(world, 'Poké Cup - Tier Upgrade', 3, ItemClassification.progression)
|
||||
item_pool += create_multiple_items(world, 'Prime Cup - Tier Upgrade', 3, ItemClassification.progression)
|
||||
|
||||
item_pool += create_multiple_items(world, 'GLC PC Box Upgrade', 6, ItemClassification.useful)
|
||||
item_pool += create_multiple_items(world, 'Poke Cup PC Box Upgrade', 6, ItemClassification.useful)
|
||||
item_pool += create_multiple_items(world, 'Prime Cup PC Box Upgrade', 6, ItemClassification.useful)
|
||||
|
||||
item_pool += create_junk_items(world, get_total_locations(world) - len(item_pool) - 1)
|
||||
|
||||
return item_pool
|
||||
|
||||
def create_item(world: 'PokemonStadiumWorld', name: str) -> Item:
|
||||
data = item_table[name]
|
||||
return PokemonStadiumItem(name, data.classification, data.ap_code, world.player)
|
||||
|
||||
def create_multiple_items(world: "PokemonStadiumWorld", name: str, count: int, item_type: ItemClassification = ItemClassification.progression) -> List[Item]:
|
||||
data = item_table[name]
|
||||
itemlist: List[Item] = []
|
||||
|
||||
for _ in range(count):
|
||||
itemlist += [PokemonStadiumItem(name, item_type, data.ap_code, world.player)]
|
||||
|
||||
return itemlist
|
||||
|
||||
def create_junk_items(world: 'PokemonStadiumWorld', count: int) -> List[Item]:
|
||||
junk_pool: List[Item] = []
|
||||
junk_list: Dict[str, int] = {}
|
||||
|
||||
for name in item_table.keys():
|
||||
ic = item_table[name].classification
|
||||
if ic == ItemClassification.filler:
|
||||
junk_list[name] = junk_weights.get(name)
|
||||
|
||||
for _ in range(count):
|
||||
junk_pool.append(world.create_item(world.random.choices(list(junk_list.keys()), weights=list(junk_list.values()), k=1)[0]))
|
||||
|
||||
return junk_pool
|
||||
|
||||
pokemon_stadium_items = {
|
||||
# Progression items
|
||||
'Pewter City Key': ItemData(10000001, ItemClassification.progression),
|
||||
'Boulder Badge': ItemData(10000002, ItemClassification.progression),
|
||||
'Cerulean City Key': ItemData(10000003, ItemClassification.progression),
|
||||
'Cascade Badge': ItemData(10000004, ItemClassification.progression),
|
||||
'Vermillion City Key': ItemData(10000005, ItemClassification.progression),
|
||||
'Thunder Badge': ItemData(10000006, ItemClassification.progression),
|
||||
'Celadon City Key': ItemData(10000007, ItemClassification.progression),
|
||||
'Rainbow Badge': ItemData(10000008, ItemClassification.progression),
|
||||
'Fuchsia City Key': ItemData(10000009, ItemClassification.progression),
|
||||
'Soul Badge': ItemData(10000010, ItemClassification.progression),
|
||||
'Saffron City Key': ItemData(10000011, ItemClassification.progression),
|
||||
'Marsh Badge': ItemData(10000012, ItemClassification.progression),
|
||||
'Cinnabar Island Key': ItemData(10000013, ItemClassification.progression),
|
||||
'Volcano Badge': ItemData(10000014, ItemClassification.progression),
|
||||
'Viridian City Key': ItemData(10000015, ItemClassification.progression),
|
||||
'Earth Badge': ItemData(10000016, ItemClassification.progression),
|
||||
|
||||
# Victory is added here since in this organization it needs to be in the default item pool
|
||||
'Victory': ItemData(10000000, ItemClassification.progression)
|
||||
}
|
||||
|
||||
gym_keys = [
|
||||
'Pewter City Key',
|
||||
'Cerulean City Key',
|
||||
'Vermillion City Key',
|
||||
'Celadon City Key',
|
||||
'Fuchsia City Key',
|
||||
'Saffron City Key',
|
||||
'Cinnabar Island Key',
|
||||
'Viridian City Key',
|
||||
]
|
||||
|
||||
gym_badge_codes = [
|
||||
10000002,
|
||||
10000004,
|
||||
10000006,
|
||||
10000008,
|
||||
10000010,
|
||||
10000012,
|
||||
10000014,
|
||||
10000016,
|
||||
]
|
||||
|
||||
cup_tier_upgrade_items = {
|
||||
'Poké Cup - Tier Upgrade': ItemData(10000017, ItemClassification.progression),
|
||||
'Prime Cup - Tier Upgrade': ItemData(10000018, ItemClassification.progression),
|
||||
}
|
||||
|
||||
box_upgrade_items = {
|
||||
'GLC PC Box Upgrade': ItemData(10000101, ItemClassification.useful),
|
||||
'Poke Cup PC Box Upgrade' : ItemData(10000102, ItemClassification.useful),
|
||||
'Prime Cup PC Box Upgrade' : ItemData(10000103, ItemClassification.useful),
|
||||
}
|
||||
|
||||
junk_items = {
|
||||
"Pokedoll": ItemData(10000200, ItemClassification.filler, 0),
|
||||
}
|
||||
|
||||
junk_weights = {
|
||||
"Pokedoll": 40,
|
||||
}
|
||||
|
||||
item_table = {
|
||||
**pokemon_stadium_items,
|
||||
**cup_tier_upgrade_items,
|
||||
**box_upgrade_items,
|
||||
**junk_items,
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
from typing import Dict, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from .Types import LocData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PokemonStadiumWorld
|
||||
|
||||
def get_total_locations(world: 'PokemonStadiumWorld') -> int:
|
||||
if world.options.Trainersanity.value == 1:
|
||||
location_table.update(trainersanity_locations)
|
||||
|
||||
return len(location_table)
|
||||
|
||||
def get_location_names() -> Dict[str, int]:
|
||||
temp_loc_table = location_table.copy()
|
||||
temp_loc_table.update(trainersanity_locations)
|
||||
|
||||
names = {name: data.ap_code for name, data in temp_loc_table.items()}
|
||||
|
||||
return names
|
||||
|
||||
def is_valid_location(world: 'PokemonStadiumWorld', name) -> bool:
|
||||
return True
|
||||
|
||||
pokemon_stadium_locations = {
|
||||
'Magikarp\'s Splash': LocData(20000100, 'Kids Club'),
|
||||
'Clefairy Says': LocData(20000101, 'Kids Club'),
|
||||
'Run, Rattata, Run': LocData(20000102, 'Kids Club'),
|
||||
'Snore War': LocData(20000103, 'Kids Club'),
|
||||
'Thundering Dynamo': LocData(20000104, 'Kids Club'),
|
||||
'Sushi-Go-Round': LocData(20000105, 'Kids Club'),
|
||||
'Ekans\'s Hoop Hurl': LocData(20000106, 'Kids Club'),
|
||||
'Rock Harden': LocData(20000107, 'Kids Club'),
|
||||
'Dig! Dig! Dig!': LocData(20000108, 'Kids Club'),
|
||||
|
||||
'Poké Cup - Poké Ball - Prize': LocData(20000300, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Tier Upgrade': LocData(20000309, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Prize': LocData(20000310, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Tier Upgrade': LocData(20000319, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Prize': LocData(20000320, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Tier Upgrade': LocData(20000329, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Prize': LocData(20000330, 'Poké Cup'),
|
||||
|
||||
'Petit Cup Prize': LocData(20000400, 'Petit Cup'),
|
||||
|
||||
'Pika Cup Prize': LocData(20000500, 'Pika Cup'),
|
||||
|
||||
'Prime Cup - Poké Ball - Prize': LocData(20000600, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Tier Upgrade': LocData(20000609, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Prize': LocData(20000610, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Tier Upgrade': LocData(20000619, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Prize': LocData(20000620, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Tier Upgrade': LocData(20000629, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Prize': LocData(20000630, 'Prime Cup'),
|
||||
|
||||
'BROCK': LocData(20000704, 'Gym Leader Castle'),
|
||||
'Pewter Gym': LocData(20000705, 'Gym Leader Castle'),
|
||||
'MISTY': LocData(20000714, 'Gym Leader Castle'),
|
||||
'Cerulean Gym': LocData(20000715, 'Gym Leader Castle'),
|
||||
'SURGE': LocData(20000724, 'Gym Leader Castle'),
|
||||
'Vermillion Gym': LocData(20000725, 'Gym Leader Castle'),
|
||||
'ERIKA': LocData(20000734, 'Gym Leader Castle'),
|
||||
'Celadon Gym': LocData(20000735, 'Gym Leader Castle'),
|
||||
'KOGA': LocData(20000744, 'Gym Leader Castle'),
|
||||
'Fuchsia Gym': LocData(20000745, 'Gym Leader Castle'),
|
||||
'SABRINA': LocData(20000754, 'Gym Leader Castle'),
|
||||
'Saffron Gym': LocData(20000755, 'Gym Leader Castle'),
|
||||
'BLAINE': LocData(20000764, 'Gym Leader Castle'),
|
||||
'Cinnabar Gym': LocData(20000765, 'Gym Leader Castle'),
|
||||
'GIOVANNI': LocData(20000774, 'Gym Leader Castle'),
|
||||
'Viridian Gym': LocData(20000775, 'Gym Leader Castle'),
|
||||
}
|
||||
|
||||
event_locations = {
|
||||
'Beat Rival': LocData(20000000, 'Hall of Fame')
|
||||
}
|
||||
|
||||
trainersanity_locations = {
|
||||
'Poké Cup - Poké Ball - Bug Boy': LocData(20000301, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Lad': LocData(20000302, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Nerd': LocData(20000303, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Sailor': LocData(20000304, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Jr(F)': LocData(20000305, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Jr(M)': LocData(20000306, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Lass': LocData(20000307, 'Poké Cup'),
|
||||
'Poké Cup - Poké Ball - Pokémaniac': LocData(20000308, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Bug Boy': LocData(20000311, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Lad': LocData(20000312, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Nerd': LocData(20000313, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Sailor': LocData(20000314, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Jr(F)': LocData(20000315, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Jr(M)': LocData(20000316, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Lass': LocData(20000317, 'Poké Cup'),
|
||||
'Poké Cup - Great Ball - Pokémaniac': LocData(20000318, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Bug Boy': LocData(20000321, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Lad': LocData(20000322, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Nerd': LocData(20000323, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Sailor': LocData(20000324, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Jr(F)': LocData(20000325, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Jr(M)': LocData(20000326, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Lass': LocData(20000327, 'Poké Cup'),
|
||||
'Poké Cup - Ultra Ball - Pokémaniac': LocData(20000328, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Bug Boy': LocData(20000331, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Lad': LocData(20000332, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Nerd': LocData(20000333, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Sailor': LocData(20000334, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Jr(F)': LocData(20000335, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Jr(M)': LocData(20000336, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Lass': LocData(20000337, 'Poké Cup'),
|
||||
'Poké Cup - Master Ball - Pokémaniac': LocData(20000338, 'Poké Cup'),
|
||||
|
||||
'Petit Cup - Bug Boy': LocData(20000401, 'Petit Cup'),
|
||||
'Petit Cup - Lad': LocData(20000402, 'Petit Cup'),
|
||||
'Petit Cup - Nerd': LocData(20000403, 'Petit Cup'),
|
||||
'Petit Cup - Sailor': LocData(20000404, 'Petit Cup'),
|
||||
'Petit Cup - Jr(F)': LocData(20000405, 'Petit Cup'),
|
||||
'Petit Cup - Jr(M)': LocData(20000406, 'Petit Cup'),
|
||||
'Petit Cup - Lass': LocData(20000407, 'Petit Cup'),
|
||||
'Petit Cup - Pokémaniac': LocData(20000408, 'Petit Cup'),
|
||||
|
||||
'Pika Cup - Bug Boy': LocData(20000501, 'Pika Cup'),
|
||||
'Pika Cup - Lad': LocData(20000502, 'Pika Cup'),
|
||||
'Pika Cup - Swimmer': LocData(20000503, 'Pika Cup'),
|
||||
'Pika Cup - Burglar': LocData(20000504, 'Pika Cup'),
|
||||
'Pika Cup - Mr. Fix': LocData(20000505, 'Pika Cup'),
|
||||
'Pika Cup - Hiker': LocData(20000506, 'Pika Cup'),
|
||||
'Pika Cup - Lass': LocData(20000507, 'Pika Cup'),
|
||||
'Pika Cup - Fisher': LocData(20000508, 'Pika Cup'),
|
||||
|
||||
'Prime Cup - Poké Ball - Cue Ball': LocData(20000601, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Rocket': LocData(20000602, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Judoboy': LocData(20000603, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Gambler': LocData(20000604, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Cool(F)': LocData(20000605, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Bird Boy': LocData(20000606, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Lab Man': LocData(20000607, 'Prime Cup'),
|
||||
'Prime Cup - Poké Ball - Cool(M)': LocData(20000608, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Cue Ball': LocData(20000611, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Rocket': LocData(20000612, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Judoboy': LocData(20000613, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Gambler': LocData(20000614, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Cool(F)': LocData(20000615, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Bird Boy': LocData(20000616, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Lab Man': LocData(20000617, 'Prime Cup'),
|
||||
'Prime Cup - Great Ball - Cool(M)': LocData(20000618, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Cue Ball': LocData(20000621, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Rocket': LocData(20000622, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Judoboy': LocData(20000623, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Gambler': LocData(20000624, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Cool(F)': LocData(20000625, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Bird Boy': LocData(20000626, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Lab Man': LocData(20000627, 'Prime Cup'),
|
||||
'Prime Cup - Ultra Ball - Cool(M)': LocData(20000628, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Cue Ball': LocData(20000631, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Rocket': LocData(20000632, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Judoboy': LocData(20000633, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Gambler': LocData(20000634, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Cool(F)': LocData(20000635, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Bird Boy': LocData(20000636, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Lab Man': LocData(20000637, 'Prime Cup'),
|
||||
'Prime Cup - Master Ball - Cool(M)': LocData(20000638, 'Prime Cup'),
|
||||
|
||||
'Pewter Gym - Bug Boy': LocData(20000701, 'Gym Leader Castle'),
|
||||
'Pewter Gym - Lad': LocData(20000702, 'Gym Leader Castle'),
|
||||
'Pewter Gym - Jr(M)': LocData(20000703, 'Gym Leader Castle'),
|
||||
'Cerulean Gym - Fisher': LocData(20000711, 'Gym Leader Castle'),
|
||||
'Cerulean Gym - Jr(F)': LocData(20000712, 'Gym Leader Castle'),
|
||||
'Cerulean Gym - Swimmer': LocData(20000713, 'Gym Leader Castle'),
|
||||
'Vermillion Gym - Sailor': LocData(20000721, 'Gym Leader Castle'),
|
||||
'Vermillion Gym - Rocker': LocData(20000722, 'Gym Leader Castle'),
|
||||
'Vermillion Gym - Old Man': LocData(20000723, 'Gym Leader Castle'),
|
||||
'Celadon Gym - Lass': LocData(20000731, 'Gym Leader Castle'),
|
||||
'Celadon Gym - Beauty': LocData(20000732, 'Gym Leader Castle'),
|
||||
'Celadon Gym - Cool(F)': LocData(20000733, 'Gym Leader Castle'),
|
||||
'Fuchsia Gym - Biker': LocData(20000741, 'Gym Leader Castle'),
|
||||
'Fuchsia Gym - Tamer': LocData(20000742, 'Gym Leader Castle'),
|
||||
'Fuchsia Gym - Juggler': LocData(20000743, 'Gym Leader Castle'),
|
||||
'Saffron Gym - Cue Ball': LocData(20000751, 'Gym Leader Castle'),
|
||||
'Saffron Gym - Burglar': LocData(20000752, 'Gym Leader Castle'),
|
||||
'Saffron Gym - Medium': LocData(20000753, 'Gym Leader Castle'),
|
||||
'Cinnabar Gym - Judoboy': LocData(20000761, 'Gym Leader Castle'),
|
||||
'Cinnabar Gym - Psychic': LocData(20000762, 'Gym Leader Castle'),
|
||||
'Cinnabar Gym - Nerd': LocData(20000763, 'Gym Leader Castle'),
|
||||
'Viridian Gym - Rocket': LocData(20000771, 'Gym Leader Castle'),
|
||||
'Viridian Gym - Lab Man': LocData(20000772, 'Gym Leader Castle'),
|
||||
'Viridian Gym - Cool(M)': LocData(20000773, 'Gym Leader Castle'),
|
||||
}
|
||||
|
||||
location_table = {
|
||||
**pokemon_stadium_locations,
|
||||
**event_locations
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
from typing import List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from worlds.AutoWorld import PerGameCommonOptions
|
||||
from Options import Choice, OptionGroup, Toggle, Range
|
||||
|
||||
def create_option_groups() -> List[OptionGroup]:
|
||||
option_group_list: List[OptionGroup] = []
|
||||
for name, options in pokemon_stadium_option_groups.items():
|
||||
option_group_list.append(OptionGroup(name=name, options=options))
|
||||
|
||||
return option_group_list
|
||||
|
||||
class VictoryCondition(Choice):
|
||||
"""
|
||||
Choose victory condition
|
||||
"""
|
||||
display_name = "Victory Condition"
|
||||
option_defeat_rival = 1
|
||||
option_clear_master_ball_cup = 2
|
||||
default = 1
|
||||
|
||||
class BaseStatTotalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for Pokemon BST. Stat distribution per Pokemon will follow a randomly selected distribution curve.
|
||||
The higher the selection, the more extreme a curve you may see used.
|
||||
Stat changes are universal. Rental Pokemon and enemy trainer team Pokemon use the same BSTs.
|
||||
Vanilla - No change
|
||||
Low - 3 distribution types
|
||||
Medium - 4 distribution types
|
||||
High - 5 distribution types
|
||||
"""
|
||||
display_name = "BST Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class Trainersanity(Toggle):
|
||||
"""
|
||||
Toggle on to make all Trainers into checks. This option is off by default.
|
||||
"""
|
||||
display_name = 'Trainersanity'
|
||||
option_off = 0
|
||||
option_on = 1
|
||||
default = 0
|
||||
|
||||
class GymCastleTrainerRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the enemy team and movesets in Gym Leader Castle.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Gym Castle Trainer Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PokeCupTrainerRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the enemy team and movesets in Poke Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Poke Cup Trainer Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PrimeCupTrainerRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the enemy team and movesets in Prime Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Prime Cup Trainer Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PetitCupTrainerRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the enemy team and movesets in Petit Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Petit Cup Trainer Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PikaCupTrainerRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the enemy team and movesets in Pika Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Pika Cup Trainer Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class GymCastleRentalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the rental Pokemon moves in Gym Leader Castle.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Gym Castle Rental Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PokeCupRentalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the rental Pokemon moves in the Poke Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Poke Cup Rental Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PrimeCupRentalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the rental Pokemon moves in the Prime Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Prime Cup Rental Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class PetitCupRentalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the rental Pokemon moves in the Petit Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Petit Cup Rental Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
class PikaCupRentalRandomness(Choice):
|
||||
"""
|
||||
Controls the level of randomness for the rental Pokemon moves in the Pika Cup.
|
||||
Vanilla - No change
|
||||
Low - Movesets have a status, STAB, and higher attack stat aligned move. (4th move is fully random)
|
||||
Medium - Movesets have a STAB, and higher attack stat aligned move. (3rd and 4th moves are fully random)
|
||||
High - Movesets have a higher attack stat aligned move. (all other moves are fully random)
|
||||
"""
|
||||
display_name = "Pika Cup Rental Randomness"
|
||||
option_vanilla = 1
|
||||
option_low = 2
|
||||
option_medium = 3
|
||||
option_high = 4
|
||||
default = 1
|
||||
|
||||
class RentalListShuffle(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
Manual: Select which tables are shuffled
|
||||
"""
|
||||
display_name = "Rental List Shuffle"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
option_manual = 3
|
||||
default = 1
|
||||
|
||||
class RentalListShuffleGLC(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list for the Gym Leader Castle is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
This option only matters if RentalListShuffle is set to Manual mode.
|
||||
Default is set to On
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
"""
|
||||
display_name = "RLS Manual: Gym Leader Castle"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
default = 2
|
||||
|
||||
class RentalListShufflePokeCup(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list for the Poke Cup is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
This option only matters if RentalListShuffle is set to Manual mode.
|
||||
Default is set to On
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
"""
|
||||
display_name = "RLS Manual: Poke Cup"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
default = 2
|
||||
|
||||
class RentalListShufflePrimeCup(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list for the Prime Cup is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
This option only matters if RentalListShuffle is set to Manual mode.
|
||||
Default is set to On
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
"""
|
||||
display_name = "RLS Manual: Prime Cup"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
default = 2
|
||||
|
||||
class RentalListShufflePetitCup(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list for the Petit Cup is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
This option only matters if RentalListShuffle is set to Manual mode.
|
||||
Default is set to On
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
"""
|
||||
display_name = "RLS Manual: Petit Cup"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
default = 2
|
||||
|
||||
class RentalListShufflePikaCup(Choice):
|
||||
"""
|
||||
Controls whether the rental pokemon list for the Pika Cup is randomized or not
|
||||
Instead of going in dex order, the rental tables will be shuffled
|
||||
This option only matters if RentalListShuffle is set to Manual mode.
|
||||
Default is set to On
|
||||
|
||||
Off - No change
|
||||
On - All tables shuffled
|
||||
"""
|
||||
display_name = "RLS Manual: Pika Cup"
|
||||
option_off = 1
|
||||
option_on = 2
|
||||
default = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class PokemonStadiumOptions(PerGameCommonOptions):
|
||||
VictoryCondition: VictoryCondition
|
||||
BaseStatTotalRandomness: BaseStatTotalRandomness
|
||||
Trainersanity: Trainersanity
|
||||
GymCastleTrainerRandomness: GymCastleTrainerRandomness
|
||||
PokeCupTrainerRandomness: PokeCupTrainerRandomness
|
||||
PrimeCupTrainerRandomness: PrimeCupTrainerRandomness
|
||||
PetitCupTrainerRandomness: PetitCupTrainerRandomness
|
||||
PikaCupTrainerRandomness: PikaCupTrainerRandomness
|
||||
GymCastleRentalRandomness: GymCastleRentalRandomness
|
||||
PokeCupRentalRandomness: PokeCupRentalRandomness
|
||||
PrimeCupRentalRandomness: PrimeCupRentalRandomness
|
||||
PetitCupRentalRandomness: PetitCupRentalRandomness
|
||||
PikaCupRentalRandomness: PikaCupRentalRandomness
|
||||
RentalListShuffle: RentalListShuffle
|
||||
RentalListShuffleGLC: RentalListShuffleGLC
|
||||
RentalListShufflePokeCup: RentalListShufflePokeCup
|
||||
RentalListShufflePrimeCup: RentalListShufflePrimeCup
|
||||
RentalListShufflePetitCup: RentalListShufflePetitCup
|
||||
RentalListShufflePikaCup: RentalListShufflePikaCup
|
||||
|
||||
|
||||
# This is where you organize your options
|
||||
# Its entirely up to you how you want to organize it
|
||||
pokemon_stadium_option_groups: Dict[str, List[Any]] = {
|
||||
"General Options": [
|
||||
VictoryCondition,
|
||||
BaseStatTotalRandomness,
|
||||
Trainersanity,
|
||||
],
|
||||
|
||||
"Enemy Trainer Pokemon Options": [
|
||||
GymCastleTrainerRandomness,
|
||||
PokeCupTrainerRandomness,
|
||||
PrimeCupTrainerRandomness,
|
||||
PetitCupTrainerRandomness,
|
||||
PikaCupTrainerRandomness,
|
||||
],
|
||||
"Rental Pokemon Options":
|
||||
[
|
||||
GymCastleRentalRandomness,
|
||||
PokeCupRentalRandomness,
|
||||
PrimeCupRentalRandomness,
|
||||
PetitCupRentalRandomness,
|
||||
PikaCupRentalRandomness,
|
||||
],
|
||||
"Shuffling Options":
|
||||
[ RentalListShuffle,
|
||||
RentalListShuffleGLC,
|
||||
RentalListShufflePokeCup,
|
||||
RentalListShufflePrimeCup,
|
||||
RentalListShufflePetitCup,
|
||||
RentalListShufflePikaCup],
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from BaseClasses import Region
|
||||
from .Types import PokemonStadiumLocation
|
||||
from .Locations import location_table, trainersanity_locations, is_valid_location
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PokemonStadiumWorld
|
||||
|
||||
def create_regions(world: "PokemonStadiumWorld"):
|
||||
menu = create_region(world, "Menu")
|
||||
|
||||
# ---------------------------------- Gym Leader Castle ----------------------------------
|
||||
gym_leader_castle = create_region_and_connect(world, "Gym Leader Castle", "Menu -> Gym Leader Castle", menu)
|
||||
|
||||
create_region_and_connect(world, "Elite Four", "Gym Leader Castle -> Elite Four", gym_leader_castle)
|
||||
create_region_and_connect(world, "Rival", "Elite Four -> Rival", gym_leader_castle)
|
||||
create_region_and_connect(world, "Hall of Fame", "Rival -> Hall of Fame", gym_leader_castle)
|
||||
create_region_and_connect(world, "Beat Rival", "Hall of Fame -> Beat Rival", gym_leader_castle)
|
||||
|
||||
# -------------------------------------- Kids Club --------------------------------------
|
||||
create_region_and_connect(world, "Kids Club", "Menu -> Kids Club", menu)
|
||||
|
||||
# --------------------------------------- Stadium ---------------------------------------
|
||||
stadium = create_region_and_connect(world, "Stadium", "Menu -> Stadium", menu)
|
||||
create_region_and_connect(world, "Poké Cup", "Stadium -> Poké Cup", stadium)
|
||||
create_region_and_connect(world, "Petit Cup", "Stadium -> Petit Cup", stadium)
|
||||
create_region_and_connect(world, "Pika Cup", "Stadium -> Pika Cup", stadium)
|
||||
create_region_and_connect(world, "Prime Cup", "Stadium -> Prime Cup", stadium)
|
||||
|
||||
def create_region(world: "PokemonStadiumWorld", name: str) -> Region:
|
||||
reg = Region(name, world.player, world.multiworld)
|
||||
|
||||
if world.options.Trainersanity.value == 1:
|
||||
location_table.update(trainersanity_locations)
|
||||
|
||||
for (key, data) in location_table.items():
|
||||
if data.region == name:
|
||||
if not is_valid_location(world, key):
|
||||
continue
|
||||
location = PokemonStadiumLocation(world.player, key, data.ap_code, reg)
|
||||
reg.locations.append(location)
|
||||
|
||||
world.multiworld.regions.append(reg)
|
||||
return reg
|
||||
|
||||
def create_region_and_connect(world: "PokemonStadiumWorld", name: str, entrancename: str, connected_region: Region) -> Region:
|
||||
reg: Region = create_region(world, name)
|
||||
connected_region.connect(reg, entrancename)
|
||||
return reg
|
||||
@@ -0,0 +1,136 @@
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from settings import get_settings
|
||||
import Utils
|
||||
from worlds.AutoWorld import World
|
||||
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
|
||||
|
||||
from .randomizer import stadium_randomizer
|
||||
|
||||
NOP = bytes([0x00,0x00,0x00,0x00])
|
||||
MD5Hash = "ed1378bc12115f71209a77844965ba50"
|
||||
|
||||
class PokemonStadiumProcedurePatch(APProcedurePatch, APTokenMixin):
|
||||
game = "Pokemon Stadium"
|
||||
hash = MD5Hash
|
||||
patch_file_ending = ".apstadium"
|
||||
result_file_ending = ".z64"
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
return get_base_rom_bytes()
|
||||
|
||||
def get_base_rom_bytes() -> bytes:
|
||||
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path()
|
||||
base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb")))
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
md5hash = basemd5.hexdigest()
|
||||
if MD5Hash !=md5hash:
|
||||
raise Exception("Supplied Rom does not match known MD5 for Pokemon Stadium")
|
||||
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
|
||||
return base_rom_bytes
|
||||
|
||||
def get_base_rom_path():
|
||||
file_name = get_settings()["stadium_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.user_path(file_name)
|
||||
return file_name
|
||||
|
||||
def write_tokens(world:World, patch:PokemonStadiumProcedurePatch):
|
||||
# version = settings['ROMVersion']
|
||||
bst_factor = world.options.BaseStatTotalRandomness.value
|
||||
glc_trainer_factor = world.options.GymCastleTrainerRandomness.value
|
||||
pokecup_trainer_factor = world.options.PokeCupTrainerRandomness.value
|
||||
primecup_trainer_factor = world.options.PrimeCupTrainerRandomness.value
|
||||
petitcup_trainer_factor = world.options.PetitCupTrainerRandomness.value
|
||||
pikacup_trainer_factor = world.options.PikaCupTrainerRandomness.value
|
||||
glc_rental_factor = world.options.GymCastleRentalRandomness.value
|
||||
pokecup_rental_factor = world.options.PokeCupRentalRandomness.value
|
||||
primecup_rental_factor = world.options.PrimeCupRentalRandomness.value
|
||||
petitcup_rental_factor = world.options.PetitCupRentalRandomness.value
|
||||
pikacup_rental_factor = world.options.PikaCupRentalRandomness.value
|
||||
rental_list_shuffle_factor = world.options.RentalListShuffle.value
|
||||
rental_list_shuffle_glc_factor = world.options.RentalListShuffleGLC.value
|
||||
rental_list_shuffle_poke_cup_factor = world.options.RentalListShufflePokeCup.value
|
||||
rental_list_shuffle_prime_cup_factor = world.options.RentalListShufflePrimeCup.value
|
||||
rental_list_shuffle_petit_cup_factor = world.options.RentalListShufflePetitCup.value
|
||||
rental_list_shuffle_pika_cup_factor = world.options.RentalListShufflePikaCup.value
|
||||
randomizer = stadium_randomizer.Randomizer('US_1.0', bst_factor, glc_trainer_factor, pokecup_trainer_factor, primecup_trainer_factor, petitcup_trainer_factor,
|
||||
pikacup_trainer_factor, glc_rental_factor, pokecup_rental_factor, primecup_rental_factor,petitcup_rental_factor, pikacup_rental_factor,
|
||||
rental_list_shuffle_factor, rental_list_shuffle_glc_factor, rental_list_shuffle_poke_cup_factor, rental_list_shuffle_prime_cup_factor,
|
||||
rental_list_shuffle_petit_cup_factor, rental_list_shuffle_pika_cup_factor)
|
||||
|
||||
# Bypass CIC
|
||||
randomizer.disable_checksum(patch)
|
||||
if bst_factor > 1:
|
||||
randomizer.randomize_base_stats(patch)
|
||||
if glc_trainer_factor > 1:
|
||||
randomizer.randomize_glc_trainer_pokemon_round1(patch)
|
||||
if pokecup_trainer_factor > 1:
|
||||
randomizer.randomize_pokecup_trainer_pokemon_round1(patch)
|
||||
if primecup_trainer_factor > 1:
|
||||
randomizer.randomize_primecup_trainer_pokemon_round1(patch)
|
||||
if petitcup_trainer_factor > 1:
|
||||
randomizer.randomize_petitcup_trainer_pokemon_round1(patch)
|
||||
if pikacup_trainer_factor > 1:
|
||||
randomizer.randomize_pikacup_trainer_pokemon_round1(patch)
|
||||
|
||||
if glc_rental_factor > 1:
|
||||
randomizer.randomize_glc_rentals_round1(patch)
|
||||
if pokecup_rental_factor > 1:
|
||||
randomizer.randomize_pokecup_rentals(patch)
|
||||
if primecup_rental_factor > 1:
|
||||
randomizer.randomize_primecup_rentals_round1(patch)
|
||||
if petitcup_rental_factor > 1:
|
||||
randomizer.randomize_petitcup_rentals(patch)
|
||||
if pikacup_rental_factor > 1:
|
||||
randomizer.randomize_pikacup_rentals(patch)
|
||||
if rental_list_shuffle_factor > 1:
|
||||
if rental_list_shuffle_factor != 3: #Not in manual mode
|
||||
randomizer.shuffle_rentals(patch)
|
||||
else:
|
||||
if rental_list_shuffle_glc_factor > 1:
|
||||
randomizer.shuffle_glc(patch)
|
||||
if rental_list_shuffle_poke_cup_factor > 1:
|
||||
randomizer.shuffle_poke(patch)
|
||||
if rental_list_shuffle_prime_cup_factor > 1:
|
||||
randomizer.shuffle_prime(patch)
|
||||
if rental_list_shuffle_petit_cup_factor > 1:
|
||||
randomizer.shuffle_petit(patch)
|
||||
if rental_list_shuffle_pika_cup_factor > 1:
|
||||
randomizer.shuffle_pika(patch)
|
||||
|
||||
# Set GP Register to 80420000
|
||||
patch.write_token(APTokenTypes.WRITE, 0x202B8, bytes([0x3C, 0x1C, 0x80, 0x42]))
|
||||
|
||||
# Set 'Starting Battle' flag
|
||||
patch.write_token(APTokenTypes.WRITE, 0x855C, bytes([0xAF, 0x81, 0x00, 0x10]))
|
||||
|
||||
# Clear 'Starting Battle' flag
|
||||
patch.write_token(APTokenTypes.WRITE, 0x396D08, bytes([0xAF, 0x80, 0x00, 0x10]))
|
||||
|
||||
# Turn off A and B button on GLC select screen
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3B4DA8, bytes([0x50, 0x21, 0xFF, 0x82]))
|
||||
|
||||
# First instruction to set flag for GLC selection screen
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3B5548, bytes([0xAF, 0x84, 0x00, 0x00]))
|
||||
|
||||
# Second instruction to set flag for GLC selection screen
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3B55F4, bytes([0xAF, 0x82, 0x00, 0x00]))
|
||||
|
||||
# Set selecting Poke Cup tier flag
|
||||
patch.write_token(APTokenTypes.WRITE, 0x2D6A20, bytes([0xAF, 0x93, 0x00, 0x20]))
|
||||
|
||||
# Clear selecting Poke Cup tier flag
|
||||
patch.write_token(APTokenTypes.WRITE, 0x2D6DB0, bytes([0xAF, 0x80, 0x00, 0x20]))
|
||||
|
||||
# Stop game from activating unlocked gyms
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3B5728, bytes([0xA3, 0x20, 0x00, 0x01]))
|
||||
|
||||
# Write patch file
|
||||
patch.write_file("token_data.bin", patch.get_token_binary())
|
||||
@@ -0,0 +1,132 @@
|
||||
from worlds.generic.Rules import set_rule, add_item_rule
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PokemonStadiumWorld
|
||||
|
||||
def set_rules(world: "PokemonStadiumWorld"):
|
||||
player = world.player
|
||||
options = world.options
|
||||
|
||||
# Gym Access
|
||||
set_rule(world.multiworld.get_location("Pewter Gym", player), lambda state: state.has("Pewter City Key", player))
|
||||
set_rule(world.multiworld.get_location("Cerulean Gym", player), lambda state: state.has("Cerulean City Key", player))
|
||||
set_rule(world.multiworld.get_location("Vermillion Gym", player), lambda state: state.has("Vermillion City Key", player))
|
||||
set_rule(world.multiworld.get_location("Celadon Gym", player), lambda state: state.has("Celadon City Key", player))
|
||||
set_rule(world.multiworld.get_location("Fuchsia Gym", player), lambda state: state.has("Fuchsia City Key", player))
|
||||
set_rule(world.multiworld.get_location("Saffron Gym", player), lambda state: state.has("Saffron City Key", player))
|
||||
set_rule(world.multiworld.get_location("Cinnabar Gym", player), lambda state: state.has("Cinnabar Island Key", player))
|
||||
set_rule(world.multiworld.get_location("Viridian Gym", player), lambda state: state.has("Viridian City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("BROCK", player), lambda state: state.has("Pewter City Key", player))
|
||||
set_rule(world.multiworld.get_location("MISTY", player), lambda state: state.has("Cerulean City Key", player))
|
||||
set_rule(world.multiworld.get_location("SURGE", player), lambda state: state.has("Vermillion City Key", player))
|
||||
set_rule(world.multiworld.get_location("ERIKA", player), lambda state: state.has("Celadon City Key", player))
|
||||
set_rule(world.multiworld.get_location("KOGA", player), lambda state: state.has("Fuchsia City Key", player))
|
||||
set_rule(world.multiworld.get_location("SABRINA", player), lambda state: state.has("Saffron City Key", player))
|
||||
set_rule(world.multiworld.get_location("BLAINE", player), lambda state: state.has("Cinnabar Island Key", player))
|
||||
set_rule(world.multiworld.get_location("GIOVANNI", player), lambda state: state.has("Viridian City Key", player))
|
||||
|
||||
# Cup Access
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Prize", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Prize", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Prize", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Prize", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Prize", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Prize", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
|
||||
#Trainersanity All
|
||||
if world.options.Trainersanity.value == 1:
|
||||
set_rule(world.multiworld.get_location("Pewter Gym - Bug Boy", player), lambda state: state.has("Pewter City Key", player))
|
||||
set_rule(world.multiworld.get_location("Pewter Gym - Lad", player), lambda state: state.has("Pewter City Key", player))
|
||||
set_rule(world.multiworld.get_location("Pewter Gym - Jr(M)", player), lambda state: state.has("Pewter City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Cerulean Gym - Fisher", player), lambda state: state.has("Cerulean City Key", player))
|
||||
set_rule(world.multiworld.get_location("Cerulean Gym - Jr(F)", player), lambda state: state.has("Cerulean City Key", player))
|
||||
set_rule(world.multiworld.get_location("Cerulean Gym - Swimmer", player), lambda state: state.has("Cerulean City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Vermillion Gym - Sailor", player), lambda state: state.has("Vermillion City Key", player))
|
||||
set_rule(world.multiworld.get_location("Vermillion Gym - Rocker", player), lambda state: state.has("Vermillion City Key", player))
|
||||
set_rule(world.multiworld.get_location("Vermillion Gym - Old Man", player), lambda state: state.has("Vermillion City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Celadon Gym - Lass", player), lambda state: state.has("Celadon City Key", player))
|
||||
set_rule(world.multiworld.get_location("Celadon Gym - Beauty", player), lambda state: state.has("Celadon City Key", player))
|
||||
set_rule(world.multiworld.get_location("Celadon Gym - Cool(F)", player), lambda state: state.has("Celadon City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Fuchsia Gym - Biker", player), lambda state: state.has("Fuchsia City Key", player))
|
||||
set_rule(world.multiworld.get_location("Fuchsia Gym - Tamer", player), lambda state: state.has("Fuchsia City Key", player))
|
||||
set_rule(world.multiworld.get_location("Fuchsia Gym - Juggler", player), lambda state: state.has("Fuchsia City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Saffron Gym - Cue Ball", player), lambda state: state.has("Saffron City Key", player))
|
||||
set_rule(world.multiworld.get_location("Saffron Gym - Burglar", player), lambda state: state.has("Saffron City Key", player))
|
||||
set_rule(world.multiworld.get_location("Saffron Gym - Medium", player), lambda state: state.has("Saffron City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Cinnabar Gym - Judoboy", player), lambda state: state.has("Cinnabar Island Key", player))
|
||||
set_rule(world.multiworld.get_location("Cinnabar Gym - Psychic", player), lambda state: state.has("Cinnabar Island Key", player))
|
||||
set_rule(world.multiworld.get_location("Cinnabar Gym - Nerd", player), lambda state: state.has("Cinnabar Island Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Viridian Gym - Rocket", player), lambda state: state.has("Viridian City Key", player))
|
||||
set_rule(world.multiworld.get_location("Viridian Gym - Lab Man", player), lambda state: state.has("Viridian City Key", player))
|
||||
set_rule(world.multiworld.get_location("Viridian Gym - Cool(M)", player), lambda state: state.has("Viridian City Key", player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Bug Boy", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Lad", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Nerd", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Sailor", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Jr(F)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Jr(M)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Lass", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Great Ball - Pokémaniac", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 0)
|
||||
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Bug Boy", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Lad", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Nerd", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Sailor", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Jr(F)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Jr(M)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Lass", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Ultra Ball - Pokémaniac", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 1)
|
||||
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Bug Boy", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Lad", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Nerd", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Sailor", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Jr(F)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Jr(M)", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Lass", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Poké Cup - Master Ball - Pokémaniac", player), lambda state: state.count('Poké Cup - Tier Upgrade', player) > 2)
|
||||
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Cue Ball", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Rocket", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Judoboy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Gambler", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Cool(F)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Bird Boy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Lab Man", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Great Ball - Cool(M)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 0)
|
||||
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Cue Ball", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Rocket", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Judoboy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Gambler", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Cool(F)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Bird Boy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Lab Man", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Ultra Ball - Cool(M)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 1)
|
||||
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Cue Ball", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Rocket", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Judoboy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Gambler", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Cool(F)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Bird Boy", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Lab Man", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
set_rule(world.multiworld.get_location("Prime Cup - Master Ball - Cool(M)", player), lambda state: state.count('Prime Cup - Tier Upgrade', player) > 2)
|
||||
|
||||
# Beat Rival Rule
|
||||
badges = ["Boulder Badge", "Cascade Badge", "Thunder Badge", "Rainbow Badge", "Soul Badge", "Marsh Badge", "Volcano Badge", "Earth Badge"]
|
||||
set_rule(world.multiworld.get_location("Beat Rival", player), lambda state: state.has_all(badges, player))
|
||||
|
||||
# Victory condition rule!
|
||||
world.multiworld.completion_condition[player] = lambda state: state.has("Victory", player)
|
||||
@@ -0,0 +1,18 @@
|
||||
from enum import IntEnum
|
||||
from typing import NamedTuple, Optional
|
||||
from BaseClasses import Location, Item, ItemClassification
|
||||
|
||||
class PokemonStadiumLocation(Location):
|
||||
game = 'PokemonStadium'
|
||||
|
||||
class PokemonStadiumItem(Item):
|
||||
game = 'PokemonStadium'
|
||||
|
||||
class ItemData(NamedTuple):
|
||||
ap_code: Optional[int]
|
||||
classification: ItemClassification
|
||||
count: Optional[int] = 1
|
||||
|
||||
class LocData(NamedTuple):
|
||||
ap_code: Optional[int]
|
||||
region: Optional[str]
|
||||
@@ -0,0 +1,148 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import random
|
||||
|
||||
from BaseClasses import MultiWorld, Item, Tutorial
|
||||
import settings
|
||||
from typing import Dict
|
||||
import Utils
|
||||
from worlds.AutoWorld import World, CollectionState, WebWorld
|
||||
|
||||
from .Client import PokemonStadiumClient # Unused, but required to register with BizHawkClient
|
||||
from .Items import create_item, create_itempool, gym_keys, item_table
|
||||
from .Locations import get_location_names, get_total_locations
|
||||
from .Options import PokemonStadiumOptions
|
||||
from .Regions import create_regions
|
||||
from .Rom import MD5Hash, PokemonStadiumProcedurePatch, write_tokens
|
||||
from .Rom import get_base_rom_path as get_base_rom_path
|
||||
from .Rules import set_rules
|
||||
|
||||
class PokemonStadiumSettings(settings.Group):
|
||||
class PokemonStadiumRomFile(settings.UserFilePath):
|
||||
"""File name of the Pokemon Stadium (US, 1.0) ROM"""
|
||||
description = "Pokemon Stadium (US, 1.0) ROM File"
|
||||
copy_to = "Pokemon Stadium (US, 1.0).z64"
|
||||
md5s = [PokemonStadiumProcedurePatch.hash]
|
||||
|
||||
rom_file: PokemonStadiumRomFile = PokemonStadiumRomFile(PokemonStadiumRomFile.copy_to)
|
||||
|
||||
class PokemonStadiumWeb(WebWorld):
|
||||
theme = "Party"
|
||||
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up (the game you are randomizing) for Archipelago. "
|
||||
"This guide covers single-player, multiworld, and related software.",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["JCIII"]
|
||||
)]
|
||||
|
||||
class PokemonStadiumWorld(World):
|
||||
game = "Pokemon Stadium"
|
||||
|
||||
settings_key = "stadium_options"
|
||||
settings: PokemonStadiumSettings
|
||||
|
||||
item_name_to_id = {name: data.ap_code for name, data in item_table.items()}
|
||||
|
||||
location_name_to_id = get_location_names()
|
||||
|
||||
options_dataclass = PokemonStadiumOptions
|
||||
options = PokemonStadiumOptions
|
||||
|
||||
web = PokemonStadiumWeb()
|
||||
|
||||
starting_gym_keys = random.sample(gym_keys, 3)
|
||||
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
super().__init__(multiworld, player)
|
||||
|
||||
def generate_early(self):
|
||||
for key in self.starting_gym_keys:
|
||||
self.multiworld.push_precollected(self.create_item(key))
|
||||
|
||||
def create_regions(self):
|
||||
create_regions(self)
|
||||
|
||||
def create_items(self):
|
||||
self.multiworld.itempool += create_itempool(self)
|
||||
|
||||
def create_item(self, name: str) -> Item:
|
||||
return create_item(self, name)
|
||||
|
||||
def set_rules(self):
|
||||
set_rules(self)
|
||||
|
||||
def fill_slot_data(self) -> Dict[str, object]:
|
||||
slot_data: Dict[str, object] = {
|
||||
"options": {
|
||||
"VictoryCondition": self.options.VictoryCondition.value,
|
||||
"BaseStatTotalRandomness": self.options.BaseStatTotalRandomness.value,
|
||||
"Trainersanity": self.options.Trainersanity.value,
|
||||
"GymCastleTrainerRandomness": self.options.GymCastleTrainerRandomness.value,
|
||||
"PokeCupTrainerRandomness": self.options.PokeCupTrainerRandomness.value,
|
||||
"PrimeCupTrainerRandomness": self.options.PrimeCupTrainerRandomness.value,
|
||||
"PetitupTrainerRandomness": self.options.PetitCupTrainerRandomness.value,
|
||||
"PikaCupTrainerRandomness": self.options.PikaCupTrainerRandomness.value,
|
||||
"GymCastleRentalRandomness": self.options.GymCastleRentalRandomness.value,
|
||||
"PokeCupRentalRandomness": self.options.PokeCupRentalRandomness.value,
|
||||
"PrimeCupRentalRandomness": self.options.PrimeCupRentalRandomness.value,
|
||||
"PetitCupRentalRandomness": self.options.PetitCupRentalRandomness.value,
|
||||
"RentalListShuffle": self.options.RentalListShuffle.value,
|
||||
"RentalListShuffleGLC": self.options.RentalListShuffleGLC.value,
|
||||
"RentalListShufflePokeCup": self.options.RentalListShufflePokeCup.value,
|
||||
"RentalListShufflePrimeCup": self.options.RentalListShufflePrimeCup.value,
|
||||
"RentalListShufflePetitCup": self.options.RentalListShufflePetitCup.value,
|
||||
"RentalListShufflePikaCup": self.options.RentalListShufflePikaCup.value,
|
||||
},
|
||||
"Seed": self.multiworld.seed_name, # to verify the server's multiworld
|
||||
"Slot": self.multiworld.player_name[self.player], # to connect to server
|
||||
"TotalLocations": get_total_locations(self) # get_total_locations(self) comes from Locations.py
|
||||
}
|
||||
|
||||
return slot_data
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
# === Step 1: Build ROM and player metadata ===
|
||||
outfilepname = f"_P{self.player}_"
|
||||
outfilepname += f"{self.multiworld.get_file_safe_player_name(self.player).replace(' ', '_')}"
|
||||
|
||||
# ROM name metadata (embedded in ROM for client/UI)
|
||||
self.rom_name_text = f'PokemonStadium{Utils.__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed:011}\0'
|
||||
self.romName = bytearray(self.rom_name_text, "utf8")[:0x20]
|
||||
self.romName.extend([0] * (0x20 - len(self.romName))) # pad to 0x20
|
||||
self.rom_name = self.romName
|
||||
|
||||
# Player name metadata
|
||||
self.playerName = bytearray(self.multiworld.player_name[self.player], "utf8")[:0x20]
|
||||
self.playerName.extend([0] * (0x20 - len(self.playerName)))
|
||||
|
||||
# === Step 3: Create procedure patch object ===
|
||||
patch = PokemonStadiumProcedurePatch(
|
||||
player=self.player,
|
||||
player_name=self.multiworld.player_name[self.player]
|
||||
)
|
||||
|
||||
# === Step 4: Apply token modifications directly ===
|
||||
write_tokens(self, patch)
|
||||
procedure = [("apply_tokens", ["token_data.bin"])]
|
||||
|
||||
# === Step 6: Finalize procedure ===
|
||||
patch.procedure = procedure
|
||||
|
||||
# Generate output file path
|
||||
out_file_name = self.multiworld.get_out_file_name_base(self.player)
|
||||
patch_file_path = os.path.join(output_directory, f"{out_file_name}{patch.patch_file_ending}")
|
||||
|
||||
# Write the final patch file (.bps)
|
||||
patch.write(patch_file_path)
|
||||
|
||||
def collect(self, state: "CollectionState", item: "Item") -> bool:
|
||||
return super().collect(state, item)
|
||||
|
||||
def remove(self, state: "CollectionState", item: "Item") -> bool:
|
||||
return super().remove(state, item)
|
||||
@@ -0,0 +1 @@
|
||||
stop making me do this
|
||||
Loaded 100 of 2639 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user